Skip to main content

lash_core/runtime/process_worker/
mod.rs

1use std::collections::BTreeSet;
2use std::sync::Arc;
3
4use tokio_util::sync::CancellationToken;
5
6use super::effect::ProcessRunner;
7use super::session_manager::RuntimeSessionServices;
8use super::{EmbeddedRuntimeBuilder, ProcessWorkDriver, QueuedWorkDriver, RuntimeHostConfig};
9use crate::{
10    AbandonEvidence, AbandonWriter, InMemorySessionStore, LashRuntime, PluginError, PluginFactory,
11    PluginHost, PluginStack, ProcessAwaitOutput, ProcessExecutionContext, ProcessInput,
12    ProcessLease, ProcessLeaseCompletion, ProcessRecord, ProcessRegistration, ProcessRegistry,
13    RecoveryDisposition, SessionStoreFactory,
14};
15
16/// Deployment-local configuration for rebuilding durable process executions.
17///
18/// Process rows intentionally carry only portable process input and provenance.
19/// Workers provide plugins, providers, stores, secrets, and host capabilities
20/// for the deployment that owns those rows.
21#[derive(Clone)]
22pub struct DurableProcessWorkerConfig {
23    pub plugin_host: Arc<PluginHost>,
24    pub runtime_host: RuntimeHostConfig,
25    pub session_policy: crate::SessionPolicy,
26    pub session_store_factory: Arc<dyn SessionStoreFactory>,
27    pub process_registry: Arc<dyn ProcessRegistry>,
28    pub process_change_hub: Option<crate::ProcessChangeHub>,
29    pub trigger_store: Arc<dyn crate::TriggerStore>,
30    pub process_work_driver: Option<ProcessWorkDriver>,
31    pub queued_work_driver: Option<QueuedWorkDriver>,
32    #[doc(hidden)]
33    pub turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
34    /// Residency for sessions the worker rebuilds to run a process. Defaults to
35    /// [`Residency::KeepAll`]; a host running [`Residency::ActivePathOnly`] wires
36    /// it here so the worker's rebuilt sessions trim to the active path too,
37    /// instead of silently diverging from the live runtime by keeping the full
38    /// graph resident.
39    pub residency: crate::Residency,
40    /// Owner identity stem this worker derives per-recovery lease owners from.
41    ///
42    /// Each recovery attempt claims with a unique `(owner_id, incarnation_id)`
43    /// derived from this identity — a live lease held by an earlier attempt
44    /// must fence a later sweep pass rather than be re-entered as the same
45    /// incarnation — while the liveness metadata is inherited as-is. Defaults
46    /// to a fresh opaque identity per config. Hosts that run one worker per OS
47    /// process should wire a
48    /// [`LeaseOwnerIdentity::local_process`](crate::LeaseOwnerIdentity::local_process)
49    /// identity so peers on the same host can prove a crashed worker dead and
50    /// reclaim its process leases before the TTL — mirroring the session
51    /// execution lane's runtime lease owner.
52    pub lease_owner: crate::LeaseOwnerIdentity,
53}
54
55impl DurableProcessWorkerConfig {
56    pub fn new(
57        plugin_host: Arc<PluginHost>,
58        runtime_host: RuntimeHostConfig,
59        session_store_factory: Arc<dyn SessionStoreFactory>,
60        process_registry: Arc<dyn ProcessRegistry>,
61    ) -> Self {
62        let clock = Arc::clone(&runtime_host.clock);
63        Self {
64            plugin_host,
65            runtime_host,
66            session_policy: crate::SessionPolicy::default(),
67            session_store_factory,
68            process_registry,
69            process_change_hub: None,
70            trigger_store: Arc::new(crate::InMemoryTriggerStore::with_clock(clock)),
71            process_work_driver: None,
72            queued_work_driver: None,
73            turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot::default(),
74            residency: crate::Residency::default(),
75            lease_owner: crate::LeaseOwnerIdentity::opaque(
76                format!("durable-process-worker:{}", uuid::Uuid::new_v4()),
77                uuid::Uuid::new_v4().to_string(),
78            ),
79        }
80    }
81
82    pub fn with_trigger_store(mut self, store: Arc<dyn crate::TriggerStore>) -> Self {
83        self.trigger_store = store;
84        self
85    }
86
87    pub fn with_session_policy(mut self, policy: crate::SessionPolicy) -> Self {
88        self.session_policy = policy;
89        self
90    }
91
92    pub fn with_residency(mut self, residency: crate::Residency) -> Self {
93        self.residency = residency;
94        self
95    }
96
97    pub fn with_process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
98        self.process_work_driver = Some(driver);
99        self
100    }
101
102    pub fn with_change_hub(mut self, hub: crate::ProcessChangeHub) -> Self {
103        self.process_change_hub = Some(hub);
104        self
105    }
106
107    /// Set the owner identity this worker presents when claiming process
108    /// leases. See [`DurableProcessWorkerConfig::lease_owner`].
109    pub fn with_lease_owner(mut self, lease_owner: crate::LeaseOwnerIdentity) -> Self {
110        self.lease_owner = lease_owner;
111        self
112    }
113
114    pub fn with_queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
115        self.queued_work_driver = Some(driver);
116        self
117    }
118
119    #[doc(hidden)]
120    pub fn with_turn_phase_probe_slot(
121        mut self,
122        slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
123    ) -> Self {
124        self.turn_phase_probe_slot = slot;
125        self
126    }
127
128    pub fn from_plugin_factories(
129        plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
130        runtime_host: RuntimeHostConfig,
131        session_store_factory: Arc<dyn SessionStoreFactory>,
132        process_registry: Arc<dyn ProcessRegistry>,
133    ) -> Self {
134        Self::new(
135            Arc::new(PluginHost::new(plugin_factories.into_iter().collect())),
136            runtime_host,
137            session_store_factory,
138            process_registry,
139        )
140    }
141
142    pub fn from_plugin_stack(
143        plugin_stack: PluginStack,
144        runtime_host: RuntimeHostConfig,
145        session_store_factory: Arc<dyn SessionStoreFactory>,
146        process_registry: Arc<dyn ProcessRegistry>,
147    ) -> Self {
148        Self::from_plugin_factories(
149            plugin_stack.into_factories(),
150            runtime_host,
151            session_store_factory,
152            process_registry,
153        )
154    }
155}
156
157/// Reconstructable background-process worker.
158#[derive(Clone)]
159pub struct DurableProcessWorker {
160    config: Arc<DurableProcessWorkerConfig>,
161}
162
163/// Report from a graceful [owner drain](DurableProcessWorker::drain_owner_bound_work).
164#[derive(Clone, Debug, Default, PartialEq, Eq)]
165pub struct ProcessDrainReport {
166    /// Process ids this host's own started `OwnerBound` work was terminalized as
167    /// `Abandoned{OwnerDrain}` on, in the order they were drained.
168    pub abandoned: Vec<String>,
169}
170
171/// Why a recovery run did not produce a terminal outcome under the lease.
172enum RecoverFailure {
173    /// The lease was lost mid-run (another owner reclaimed an expired lease).
174    /// The losing worker must not write a terminal outcome — the new owner is
175    /// now the single writer.
176    LeaseLost(PluginError),
177    /// The process could not be run (rebuild/store-facet failure). The lease is
178    /// still held, so this worker terminalizes the row.
179    Run(PluginError),
180}
181
182impl DurableProcessWorker {
183    pub fn new(config: DurableProcessWorkerConfig) -> Self {
184        Self {
185            config: Arc::new(config),
186        }
187    }
188
189    pub fn from_shared_config(config: Arc<DurableProcessWorkerConfig>) -> Self {
190        Self { config }
191    }
192
193    pub fn config(&self) -> &DurableProcessWorkerConfig {
194        &self.config
195    }
196
197    pub async fn run_process(
198        &self,
199        registration: ProcessRegistration,
200        execution_context: ProcessExecutionContext,
201        cancellation: CancellationToken,
202    ) -> Result<ProcessAwaitOutput, PluginError> {
203        let scoped_effect_controller = self
204            .config
205            .runtime_host
206            .control
207            .effect_host
208            .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
209            .map_err(|err| PluginError::Session(err.to_string()))?
210            .ok_or_else(|| {
211                PluginError::Session(
212                    "process worker effect host must provide a static process scope".to_string(),
213                )
214            })?;
215        self.run_process_with_scoped_effect_controller(
216            registration,
217            execution_context,
218            scoped_effect_controller,
219            cancellation,
220        )
221        .await
222    }
223
224    pub async fn run_process_with_scoped_effect_controller(
225        &self,
226        registration: ProcessRegistration,
227        execution_context: ProcessExecutionContext,
228        scoped_effect_controller: crate::ScopedEffectController<'_>,
229        cancellation: CancellationToken,
230    ) -> Result<ProcessAwaitOutput, PluginError> {
231        self.ensure_stable_process_id(&registration)?;
232        self.ensure_durable_store_facets()?;
233        // Externally-owned rows are never executed by lash (ADR 0019). Reject the
234        // disposition before touching a runtime — the old fabricated-success path
235        // for External inputs is deleted.
236        if registration.disposition == RecoveryDisposition::ExternallyOwned {
237            return Err(PluginError::Session(format!(
238                "process `{}` is externally-owned and must not be executed by lash",
239                registration.id
240            )));
241        }
242        // Durable, lease-fenced "execution started" fact: recorded immediately
243        // before executing so a later sweep can distinguish a started OwnerBound
244        // row (never re-run) from an unstarted one (runnable by anyone). This is
245        // the shared run path both the inline sweep and the Restate run handler
246        // funnel through. First-writer-wins, so a re-invocation is a no-op.
247        self.config
248            .process_registry
249            .record_first_started(
250                &registration.id,
251                crate::ProcessStarted {
252                    owner: self.config.lease_owner.clone(),
253                    started_at_ms: self.now_ms(),
254                },
255            )
256            .await?;
257        let mut runtime = self.runtime_for_registration(&registration).await?;
258        let originator_scope = if let crate::ProcessOriginator::Session { scope } =
259            &registration.provenance.originator
260        {
261            Some(scope)
262        } else {
263            None
264        };
265        let probe_scope = registration.wake_target.as_ref().or(originator_scope);
266        if let Some(probe) =
267            probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
268        {
269            runtime.set_turn_phase_probe(probe);
270        }
271        let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
272            PluginError::Session(format!(
273                "failed to build runtime env for process `{}`: {err}",
274                registration.id
275            ))
276        })?;
277        Ok(manager
278            .run_process(
279                registration,
280                execution_context,
281                Arc::clone(&self.config.process_registry),
282                scoped_effect_controller,
283                cancellation,
284            )
285            .await)
286    }
287
288    /// Sweep the registry for non-terminal processes and re-execute the ones
289    /// this worker can claim, driving each to a terminal state.
290    ///
291    /// This is the crash-recovery counterpart to a worker that ran a process
292    /// from a live turn: a trigger/trigger-started process whose worker
293    /// died mid-flight is left non-terminal in the registry, and a subsequent
294    /// worker reopening that registry must finish it. The sweep:
295    ///
296    /// 1. lists every non-terminal process ([`ProcessRegistry::list_non_terminal`]);
297    /// 2. claims the durable single-owner [`ProcessLease`] over each — a process
298    ///    already leased live by *another* owner is skipped (it is being run by
299    ///    that owner right now) unless persisted liveness metadata proves that
300    ///    owner definitely dead, in which case the lease is reclaimed with the
301    ///    fenced CAS discipline of
302    ///    [`ProcessRegistry::reclaim_process_lease`]; either way a non-terminal
303    ///    process is re-run by exactly one owner (lease fencing);
304    /// 3. runs the claimed process on this worker's wired controller, renewing
305    ///    the lease across the long-running execution so a healthy recovery is
306    ///    not swept out from under itself;
307    /// 4. atomically writes the terminal outcome and releases the validated lease.
308    ///
309    /// Idempotent by `process_id`: terminal processes are never in the worklist,
310    /// and a process that became terminal between the list and the claim is
311    /// detected after claiming and skipped, so re-running a recovery sweep does
312    /// not double-execute completed work.
313    pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
314        self.reconcile_trigger_deliveries().await?;
315        let records = self.config.process_registry.list_non_terminal().await?;
316        for record in records {
317            // Run each claimed process on its OWN lease-fenced task. A sequential
318            // drive that awaited each process to terminal would deadlock a process
319            // that blocks awaiting a nested child (`start child` then `await`, or a
320            // subagent fan-out): the one drive task would park inside the parent's
321            // await and never claim the child. Spawning frees the loop so a
322            // subsequent host-driven pass claims and runs the child, and the
323            // per-process `ProcessLease` fences concurrent owners — so spawning a
324            // task per pending row on every drive is idempotent (a row already
325            // running is skipped on claim conflict) and one failing row never
326            // aborts the rest of the sweep.
327            let worker = self.clone();
328            tokio::spawn(async move { worker.recover_process(record).await });
329        }
330        Ok(())
331    }
332
333    async fn reconcile_trigger_deliveries(&self) -> Result<(), PluginError> {
334        let subscriptions = self
335            .config
336            .trigger_store
337            .list_subscriptions(crate::TriggerSubscriptionFilter::default())
338            .await?;
339        if subscriptions.is_empty() {
340            return Ok(());
341        }
342        let router = crate::TriggerRouter::new(
343            Arc::clone(&self.config.trigger_store),
344            Some(Arc::clone(&self.config.process_registry)),
345            self.config.process_work_driver.clone(),
346        );
347        let mut seen = BTreeSet::new();
348        let mut started_any = false;
349        for subscription in subscriptions {
350            let deliveries = self
351                .config
352                .trigger_store
353                .list_deliveries_by_subscription_id(&subscription.subscription_id)
354                .await?;
355            for delivery in deliveries {
356                let delivery_key = (
357                    delivery.occurrence.occurrence_id.clone(),
358                    delivery.subscription.subscription_id.clone(),
359                );
360                if !seen.insert(delivery_key) {
361                    continue;
362                }
363                if self
364                    .config
365                    .process_registry
366                    .get_process(&delivery.process_id)
367                    .await
368                    .is_some()
369                {
370                    continue;
371                }
372                let Some(scoped_effect_controller) = self
373                    .config
374                    .runtime_host
375                    .control
376                    .effect_host
377                    .scoped_static(crate::ExecutionScope::runtime_operation(format!(
378                        "trigger-delivery-reconcile:{}",
379                        delivery.process_id
380                    )))
381                    .map_err(|err| PluginError::Session(err.to_string()))?
382                else {
383                    return Err(PluginError::Session(
384                        "process worker effect host must provide a static trigger delivery reconcile scope"
385                            .to_string(),
386                    ));
387                };
388                match router
389                    .start_delivery(
390                        &delivery,
391                        Arc::clone(&self.config.process_registry),
392                        scoped_effect_controller.controller(),
393                    )
394                    .await
395                {
396                    Ok(()) => started_any = true,
397                    Err(err) => tracing::warn!(
398                        process_id = %delivery.process_id,
399                        occurrence_id = %delivery.occurrence.occurrence_id,
400                        subscription_id = %delivery.subscription.subscription_id,
401                        error = %err,
402                        "failed to reconcile trigger delivery",
403                    ),
404                }
405            }
406        }
407        if started_any && let Some(driver) = self.config.process_work_driver.as_ref() {
408            driver
409                .claim_and_run_pending("trigger_delivery_reconcile")
410                .await?;
411        }
412        Ok(())
413    }
414
415    /// Graceful owner drain: terminalize this host's own started `OwnerBound`
416    /// work as `Abandoned{OwnerDrain}` at close (ADR 0019).
417    ///
418    /// This is an explicit **host lever on the worker**, never an implicit
419    /// consequence of closing a session. Processes are global and outlive any
420    /// one session ([ADR 0011]), so `LashSession::close`/`park` must not touch
421    /// them; a host that wants its in-flight owner-bound work terminalized at
422    /// shutdown calls this on the worker it is tearing down.
423    ///
424    /// Drain sequence (the operations runbook owns the surrounding steps; this
425    /// is the terminal-writing step):
426    /// 1. stop admitting new work to this worker;
427    /// 2. cancel or await the worker's in-flight run tasks so they release their
428    ///    per-run leases — for **Rerunnable** in-flight work that is the whole
429    ///    story: stopping the local run task without any terminal write leaves
430    ///    the row non-terminal so the next worker re-runs it (its contract);
431    /// 3. call this lever: for every non-terminal **OwnerBound** row this exact
432    ///    worker started (`first_started.owner == self.config.lease_owner`),
433    ///    claim a fresh drain lease and, being the owner completing its own
434    ///    work, write `Abandoned{OwnerDrain}` under it — the ordinary graceful
435    ///    completion path, respecting the single-writer rule.
436    ///
437    /// A row still held by a live foreign lease (an in-flight run under one of
438    /// this worker's own recovery incarnations that step 2 has not yet released)
439    /// is deferred rather than reclaimed, so the drain never races a still-live
440    /// run; such a row reaches `Abandoned` on the next drain pass or at a peer's
441    /// recovery sweep. Rows started by a different owner, not-yet-started
442    /// OwnerBound rows (still claimable by anyone), Rerunnable rows, and
443    /// Externally-Owned rows are all left untouched.
444    ///
445    /// [ADR 0011]: durable process registration is session-independent.
446    pub async fn drain_owner_bound_work(&self) -> Result<ProcessDrainReport, PluginError> {
447        let mut abandoned = Vec::new();
448        for record in self.config.process_registry.list_non_terminal().await? {
449            if record.disposition != RecoveryDisposition::OwnerBound {
450                continue;
451            }
452            let Some(first_started) = record.first_started.as_ref() else {
453                // Never started: first execution is not re-execution, so any
454                // worker may still claim it. Draining it would strand runnable
455                // work as Abandoned.
456                continue;
457            };
458            if first_started.owner != self.config.lease_owner {
459                // Started by a different owner; not this host's to drain.
460                continue;
461            }
462            let owner = first_started.owner.clone();
463            if self.drain_one_owner_bound(&record.id, owner).await {
464                abandoned.push(record.id);
465            }
466        }
467        Ok(ProcessDrainReport { abandoned })
468    }
469
470    /// Terminalize one of this host's started OwnerBound rows as
471    /// `Abandoned{OwnerDrain}` under a freshly claimed drain lease. Returns
472    /// whether the terminal was written (`false` when the row is held by a live
473    /// foreign lease, already terminal, or the claim failed).
474    async fn drain_one_owner_bound(
475        &self,
476        process_id: &str,
477        owner: crate::LeaseOwnerIdentity,
478    ) -> bool {
479        let lease_ttl_ms = self.lease_timings().ttl_ms();
480        let drain_owner = self.recovery_lease_owner();
481        let lease = match self
482            .config
483            .process_registry
484            .claim_process_lease(process_id, &drain_owner, lease_ttl_ms)
485            .await
486        {
487            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
488            // A live run still holds the lease, or the claim failed: defer.
489            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return false,
490        };
491        if self
492            .config
493            .process_registry
494            .get_process(process_id)
495            .await
496            .is_some_and(|current| current.is_terminal())
497        {
498            self.release_or_log(&lease).await;
499            return false;
500        }
501        let evidence = AbandonEvidence {
502            writer: AbandonWriter::OwnerDrain,
503            owner: Some(owner),
504            epoch_ms: self.now_ms(),
505        };
506        self.complete_and_release(
507            &lease,
508            process_id,
509            ProcessAwaitOutput::Abandoned {
510                evidence: Box::new(evidence),
511                control: None,
512            },
513        )
514        .await;
515        true
516    }
517
518    /// Unique lease owner for one recovery attempt.
519    ///
520    /// Derived from [`DurableProcessWorkerConfig::lease_owner`]: a fresh
521    /// `(owner_id, incarnation_id)` per attempt keeps sweeps idempotent (a
522    /// still-running attempt's live lease fences later passes instead of being
523    /// re-entered as "own lease"), while the configured liveness metadata is
524    /// inherited so peers can prove a crashed worker dead and reclaim.
525    fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
526        let attempt = uuid::Uuid::new_v4();
527        crate::LeaseOwnerIdentity {
528            owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
529            incarnation_id: attempt.to_string(),
530            liveness: self.config.lease_owner.liveness.clone(),
531        }
532    }
533
534    /// Recover one non-terminal row, obeying its declared recovery disposition
535    /// (ADR 0019). The verdict per disposition:
536    ///
537    /// - **ExternallyOwned**: never claimed, never run. If a pending Abandon
538    ///   Request is present it is reconciled into `Abandoned{reconciled_request}`.
539    /// - **Rerunnable**: exactly today's behavior — claim, (re-)run, complete.
540    /// - **OwnerBound, never started**: any worker may run it (first execution is
541    ///   not re-execution); the runner records `first_started` before executing.
542    /// - **OwnerBound, started**: never re-run. A provably-dead holder yields
543    ///   `Abandoned{sweep}`; a merely silent/expired holder is left non-terminal
544    ///   unless an Abandon Request is present and the lease has lapsed, which
545    ///   yields `Abandoned{reconciled_request}`. Elapsed time alone never
546    ///   terminalizes.
547    ///
548    /// Every Abandoned write goes through `complete_process_with_lease`, which
549    /// atomically validates this sweep's fence, appends the terminal, and clears
550    /// the lease so a revenant's stale token is rejected.
551    async fn recover_process(&self, record: ProcessRecord) {
552        let process_id = record.id.clone();
553        // ExternallyOwned: lash never executes the row. The only recovery action
554        // is reconciling a pending Abandon Request; there is no owner lease to
555        // wait out.
556        if record.disposition == RecoveryDisposition::ExternallyOwned {
557            if record.abandon_request.is_some() {
558                self.reconcile_externally_owned_abandon(&process_id).await;
559            }
560            return;
561        }
562
563        let lease_ttl_ms = self.lease_timings().ttl_ms();
564        let owner = self.recovery_lease_owner();
565        // Claim the single-owner lease, distinguishing a fenced reclaim of a
566        // provably-dead holder (death evidence) from acquiring a free/expired
567        // lease (no death evidence). A live, not-provably-dead holder or a claim
568        // error leaves the row to its owner.
569        let Some((lease, dead_holder)) = self
570            .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
571            .await
572        else {
573            return;
574        };
575        // Terminal between the list and the claim. Idempotent by process_id: do
576        // not re-execute or re-terminalize a finished process.
577        if self
578            .config
579            .process_registry
580            .get_process(&process_id)
581            .await
582            .is_some_and(|current| current.is_terminal())
583        {
584            self.release_or_log(&lease).await;
585            return;
586        }
587
588        match record.disposition {
589            // Rerunnable: claim, (re-)run, complete — exactly today's behavior.
590            RecoveryDisposition::Rerunnable => self.run_and_complete(record, lease).await,
591            RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
592                // Started OwnerBound work is NEVER re-run — abandonment is the
593                // only recovery. `first_started`'s owner is the lapsed owner the
594                // reconciled-request evidence names.
595                let lapsed_owner = record
596                    .first_started
597                    .as_ref()
598                    .map(|started| started.owner.clone());
599                let evidence = if let Some(dead_holder) = dead_holder {
600                    // Holder provably dead ⇒ Abandoned{sweep}.
601                    Some(AbandonEvidence {
602                        writer: AbandonWriter::Sweep,
603                        owner: Some(dead_holder.owner),
604                        epoch_ms: self.now_ms(),
605                    })
606                } else if record.abandon_request.is_some() {
607                    // Silent/expired holder without death evidence, but an
608                    // operator authorized abandonment and the lease has lapsed
609                    // (we acquired a free/expired lease) ⇒ Abandoned{reconciled}.
610                    Some(AbandonEvidence {
611                        writer: AbandonWriter::ReconciledRequest,
612                        owner: lapsed_owner,
613                        epoch_ms: self.now_ms(),
614                    })
615                } else {
616                    // No death evidence and no authorization: elapsed time alone
617                    // never terminalizes. Leave the row non-terminal.
618                    None
619                };
620                match evidence {
621                    Some(evidence) => {
622                        self.complete_and_release(
623                            &lease,
624                            &process_id,
625                            ProcessAwaitOutput::Abandoned {
626                                evidence: Box::new(evidence),
627                                control: None,
628                            },
629                        )
630                        .await;
631                    }
632                    None => self.release_or_log(&lease).await,
633                }
634            }
635            // OwnerBound, never started: first execution is not re-execution, so
636            // any worker may run it; the runner records first_started first.
637            RecoveryDisposition::OwnerBound => self.run_and_complete(record, lease).await,
638            // Filtered above; releasing keeps the lease honest if reached.
639            RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
640        }
641    }
642
643    /// Wall-clock epoch ms from the worker's configured clock.
644    fn now_ms(&self) -> u64 {
645        self.config.runtime_host.clock.timestamp_ms()
646    }
647
648    /// Claim the recovery lease. Returns the acquired lease plus, when the claim
649    /// fenced out a provably-dead holder, that holder as death evidence. Returns
650    /// `None` when the row is held by a live (not provably-dead) owner or the
651    /// claim fails — either way this pass leaves the row to its owner.
652    async fn claim_for_recovery(
653        &self,
654        process_id: &str,
655        owner: &crate::LeaseOwnerIdentity,
656        lease_ttl_ms: u64,
657    ) -> Option<(ProcessLease, Option<ProcessLease>)> {
658        match self
659            .config
660            .process_registry
661            .claim_process_lease(process_id, owner, lease_ttl_ms)
662            .await
663        {
664            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => Some((lease, None)),
665            Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
666                if holder.owner.is_definitely_dead_for_claimant(owner) =>
667            {
668                match self
669                    .config
670                    .process_registry
671                    .reclaim_process_lease(process_id, owner, &holder, lease_ttl_ms)
672                    .await
673                {
674                    Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => {
675                        Some((lease, Some(holder)))
676                    }
677                    Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
678                }
679            }
680            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
681        }
682    }
683
684    /// Reconcile a pending Abandon Request on an externally-owned row into an
685    /// `Abandoned{reconciled_request}` terminal. Lash never executed the row, so
686    /// there is no owner lease to wait out — but the sweep claims its own lease
687    /// and completes through the atomic fenced path so it stays the single writer.
688    async fn reconcile_externally_owned_abandon(&self, process_id: &str) {
689        let lease_ttl_ms = self.lease_timings().ttl_ms();
690        let owner = self.recovery_lease_owner();
691        let lease = match self
692            .config
693            .process_registry
694            .claim_process_lease(process_id, &owner, lease_ttl_ms)
695            .await
696        {
697            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
698            // A concurrent writer holds the lease; let it finish.
699            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
700        };
701        if self
702            .config
703            .process_registry
704            .get_process(process_id)
705            .await
706            .is_some_and(|current| current.is_terminal())
707        {
708            self.release_or_log(&lease).await;
709            return;
710        }
711        let evidence = AbandonEvidence {
712            writer: AbandonWriter::ReconciledRequest,
713            // Externally-owned work has no lash execution owner to name.
714            owner: None,
715            epoch_ms: self.now_ms(),
716        };
717        self.complete_and_release(
718            &lease,
719            process_id,
720            ProcessAwaitOutput::Abandoned {
721                evidence: Box::new(evidence),
722                control: None,
723            },
724        )
725        .await;
726    }
727
728    /// (Re-)run a claimed row under its renewed lease and write the terminal
729    /// outcome, the same live-owner-is-single-writer path used before ADR 0019.
730    async fn run_and_complete(&self, record: ProcessRecord, lease: ProcessLease) {
731        let process_id = record.id.clone();
732        let registration = registration_from_record(record);
733        let execution_context = ProcessExecutionContext::default();
734        match self
735            .run_process_with_lease_renewal(registration, execution_context, lease.clone())
736            .await
737        {
738            // Ran to a terminal outcome (success or a process-level failure) while
739            // holding the lease: this owner is the single writer of the terminal.
740            Ok(output) => self.complete_and_release(&lease, &process_id, output).await,
741            // The lease was lost mid-run — another owner reclaimed the expired
742            // lease and is now running this process. Do NOT write a terminal
743            // outcome or release the lease: that would race the new owner and
744            // could record a succeeded process as Failed. Leave the row to the
745            // lease holder; it will finish (or another sweep retries it).
746            Err(RecoverFailure::LeaseLost(err)) => {
747                tracing::warn!(
748                    process_id = %process_id,
749                    error = %err,
750                    "process recovery lost its lease mid-run; deferring to the new owner",
751                );
752            }
753            // The process could not be run at all (rebuild/store-facet failure):
754            // terminalize as a recovery failure so the row leaves the worklist.
755            Err(RecoverFailure::Run(err)) => {
756                let output = ProcessAwaitOutput::Failure {
757                    class: crate::ToolFailureClass::Execution,
758                    code: "process_recovery_failed".to_string(),
759                    message: err.to_string(),
760                    raw: None,
761                    control: None,
762                };
763                self.complete_and_release(&lease, &process_id, output).await;
764            }
765        }
766    }
767
768    /// Write a recovered process's terminal outcome and release its lease in one
769    /// atomic fenced registry operation.
770    async fn complete_and_release(
771        &self,
772        lease: &ProcessLease,
773        process_id: &str,
774        output: ProcessAwaitOutput,
775    ) {
776        // Refresh first so a healthy long-running worker has a full completion
777        // window. The registry then validates this exact fence, appends the
778        // terminal event, and clears the lease in one transaction. There is no
779        // renew-then-unfenced-write gap for a stalled worker to cross.
780        let fenced = match self
781            .config
782            .process_registry
783            .renew_process_lease(lease, self.lease_timings().ttl_ms())
784            .await
785        {
786            Ok(renewed) => renewed,
787            Err(err) => {
788                tracing::warn!(
789                    process_id = %process_id,
790                    error = %err,
791                    "lost process lease before terminal write; deferring to the new owner",
792                );
793                return;
794            }
795        };
796        if let Err(err) = self
797            .config
798            .process_registry
799            .complete_process_with_lease(&fenced, output)
800            .await
801        {
802            tracing::warn!(
803                process_id = %process_id,
804                error = %err,
805                "failed to write recovered process terminal outcome",
806            );
807        }
808    }
809
810    async fn release_or_log(&self, lease: &ProcessLease) {
811        if let Err(err) = self.release_process_lease(lease).await {
812            tracing::warn!(
813                process_id = %lease.process_id,
814                error = %err,
815                "failed to release recovered process lease",
816            );
817        }
818    }
819
820    /// Run a recovered process while renewing its lease across the execution,
821    /// mirroring the turn-lease renewal that keeps a long-running effect's lease
822    /// from expiring under the live owner.
823    async fn run_process_with_lease_renewal(
824        &self,
825        registration: ProcessRegistration,
826        execution_context: ProcessExecutionContext,
827        mut lease: ProcessLease,
828    ) -> Result<ProcessAwaitOutput, RecoverFailure> {
829        let process_id = registration.id.clone();
830        let cancellation = CancellationToken::new();
831        let cancel_watcher = {
832            let awaiter = self
833                .config
834                .process_change_hub
835                .clone()
836                .map(|hub| {
837                    crate::ProcessAwaiter::new(Arc::clone(&self.config.process_registry), hub)
838                })
839                .unwrap_or_else(|| {
840                    crate::ProcessAwaiter::polling(Arc::clone(&self.config.process_registry))
841                });
842            let process_id = process_id.clone();
843            let cancellation = cancellation.clone();
844            tokio::spawn(async move {
845                match awaiter
846                    .await_event(&process_id, "process.cancel_requested", 0)
847                    .await
848                {
849                    Ok(_) => cancellation.cancel(),
850                    Err(err) => tracing::warn!(
851                        process_id = %process_id,
852                        error = %err,
853                        "process cancel watcher stopped before observing cancellation",
854                    ),
855                }
856            })
857        };
858        let pending = self.run_process(registration, execution_context, cancellation.clone());
859        tokio::pin!(pending);
860        loop {
861            tokio::select! {
862                outcome = &mut pending => {
863                    cancel_watcher.abort();
864                    return outcome.map_err(RecoverFailure::Run);
865                }
866                _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
867                    match self
868                        .config
869                        .process_registry
870                        .renew_process_lease(&lease, self.lease_timings().ttl_ms())
871                        .await
872                    {
873                        Ok(renewed) => lease = renewed,
874                        Err(err) => {
875                            cancellation.cancel();
876                            cancel_watcher.abort();
877                            return Err(RecoverFailure::LeaseLost(err));
878                        }
879                    }
880                }
881            }
882        }
883    }
884
885    fn lease_timings(&self) -> crate::LeaseTimings {
886        self.config.runtime_host.control.lease_timings
887    }
888
889    async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
890        self.config
891            .process_registry
892            .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
893            .await
894    }
895
896    pub async fn request_process_cancel(
897        &self,
898        process_id: &str,
899        reason: Option<String>,
900    ) -> Result<(), PluginError> {
901        self.config
902            .process_registry
903            .append_event(
904                process_id,
905                crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
906            )
907            .await
908            .map(|_| ())
909    }
910
911    async fn runtime_for_registration(
912        &self,
913        registration: &ProcessRegistration,
914    ) -> Result<LashRuntime, PluginError> {
915        match registration.input.as_ref() {
916            ProcessInput::SessionTurn { create_request, .. } => {
917                self.runtime_for_session_turn(registration, create_request.as_ref())
918                    .await
919            }
920            ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
921                self.runtime_for_process_env(registration).await
922            }
923            // Externally-owned rows are rejected before dispatch (ADR 0019), so an
924            // External input has no execution runtime; fail loudly rather than
925            // fabricate one.
926            ProcessInput::External { .. } => Err(PluginError::Session(format!(
927                "process `{}` is externally-owned and has no execution runtime",
928                registration.id
929            ))),
930        }
931    }
932
933    async fn runtime_for_session_turn(
934        &self,
935        registration: &ProcessRegistration,
936        create_request: &crate::SessionCreateRequest,
937    ) -> Result<LashRuntime, PluginError> {
938        let mut policy = create_request
939            .policy
940            .clone()
941            .unwrap_or_else(|| self.config.session_policy.clone());
942        if policy.recorded_provider_id().is_empty() {
943            policy.provider_id = self.config.session_policy.provider_id.clone();
944        }
945        self.build_ephemeral_runtime(
946            format!("process-session-turn:{}", registration.id),
947            policy,
948            create_request.plugin_options.clone(),
949            "session turn request",
950        )
951        .await
952    }
953
954    async fn runtime_for_process_env(
955        &self,
956        registration: &ProcessRegistration,
957    ) -> Result<LashRuntime, PluginError> {
958        let Some(env_ref) = registration.env_ref.as_ref() else {
959            return Err(PluginError::Session(format!(
960                "process `{}` is missing a captured execution env",
961                registration.id
962            )));
963        };
964        let env = crate::load_process_execution_env(
965            self.config
966                .runtime_host
967                .durability
968                .process_env_store
969                .as_ref(),
970            env_ref,
971        )
972        .await?;
973        self.build_ephemeral_runtime(
974            format!("process-env:{}", registration.id),
975            env.policy,
976            env.plugin_options,
977            env_ref.as_str(),
978        )
979        .await
980    }
981
982    async fn build_ephemeral_runtime(
983        &self,
984        session_id: String,
985        policy: crate::SessionPolicy,
986        plugin_options: crate::PluginOptions,
987        source_label: &str,
988    ) -> Result<LashRuntime, PluginError> {
989        let store = Arc::new(InMemorySessionStore::default());
990        let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
991            if let Some(hub) = self.config.process_change_hub.clone() {
992                ProcessWorkDriver::from_watched(
993                    Arc::clone(&self.config.process_registry),
994                    hub,
995                    Arc::new(crate::InlineProcessRunHandle::new(self.clone())),
996                )
997            } else {
998                ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
999            }
1000        });
1001        let mut builder = EmbeddedRuntimeBuilder::new()
1002            .with_session_id(session_id.to_string())
1003            .with_plugin_host(self.config.plugin_host.as_ref().clone())
1004            .with_runtime_host(self.config.runtime_host.clone())
1005            .with_policy(policy)
1006            .with_plugin_options(plugin_options)
1007            .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
1008            .with_trigger_store(Arc::clone(&self.config.trigger_store))
1009            .with_process_registry(Arc::clone(&self.config.process_registry))
1010            .with_process_work_driver(process_work_driver)
1011            .with_residency(self.config.residency)
1012            .with_store(store);
1013        if let Some(driver) = self.config.queued_work_driver.clone() {
1014            builder = builder.with_queued_work_driver(driver);
1015        }
1016        builder.build().await.map_err(|err| {
1017            PluginError::Session(format!(
1018                "failed to build process worker runtime for {source_label}: {err}"
1019            ))
1020        })
1021    }
1022
1023    /// Enforce the durable-first wiring invariant at the worker process-run
1024    /// boundary: when the worker was wired with a durable effect host, every
1025    /// store it will execute against must also be durable. A durable host
1026    /// running against any ephemeral store fails loudly here rather than
1027    /// silently re-executing a process against non-durable state.
1028    ///
1029    /// Inline controllers (the default tier) impose no requirement, so
1030    /// inline/in-memory workers pass unchanged.
1031    fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
1032        if self
1033            .config
1034            .runtime_host
1035            .control
1036            .effect_host
1037            .durability_tier()
1038            != crate::DurabilityTier::Durable
1039        {
1040            return Ok(());
1041        }
1042        let require = |facet: crate::DurableStoreFacet| {
1043            PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
1044        };
1045        if self
1046            .config
1047            .runtime_host
1048            .durability
1049            .attachment_store
1050            .persistence()
1051            .durability_tier()
1052            != crate::DurabilityTier::Durable
1053        {
1054            return Err(require(crate::DurableStoreFacet::AttachmentStore));
1055        }
1056        if self
1057            .config
1058            .runtime_host
1059            .durability
1060            .process_env_store
1061            .durability_tier()
1062            != crate::DurabilityTier::Durable
1063        {
1064            return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
1065        }
1066        if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
1067            return Err(require(crate::DurableStoreFacet::SessionStore));
1068        }
1069        if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
1070            return Err(require(crate::DurableStoreFacet::ProcessRegistry));
1071        }
1072        if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
1073            return Err(require(crate::DurableStoreFacet::TriggerStore));
1074        }
1075        Ok(())
1076    }
1077
1078    /// Enforce the stable-process-id invariant at every (re-)execution: process
1079    /// execution identity is the persisted `process_id`, so a retry — a Restate
1080    /// `run` re-invocation (keyed `LashProcessWorkflow/{process_id}`) or a
1081    /// recovery sweep re-running a non-terminal row — must present that stable
1082    /// id. An empty/fresh id has lost its idempotency anchor and is rejected
1083    /// loudly here, mirroring how `ExecutionScope` rejects an
1084    /// empty turn id at the durable-effect boundary.
1085    fn ensure_stable_process_id(
1086        &self,
1087        registration: &ProcessRegistration,
1088    ) -> Result<(), PluginError> {
1089        if registration.id.trim().is_empty() {
1090            return Err(PluginError::Session(
1091                crate::RuntimeError::missing_process_execution_id().to_string(),
1092            ));
1093        }
1094        Ok(())
1095    }
1096}
1097
1098/// Rebuild a runnable registration from a persisted row, preserving its declared
1099/// disposition (ADR 0019).
1100fn registration_from_record(record: ProcessRecord) -> ProcessRegistration {
1101    ProcessRegistration {
1102        id: record.id,
1103        input: record.input,
1104        disposition: record.disposition,
1105        identity: record.identity,
1106        event_types: record.event_types,
1107        provenance: record.provenance,
1108        env_ref: record.env_ref,
1109        wake_target: record.wake_target,
1110    }
1111}
1112
1113#[cfg(test)]
1114mod boundary_tests;
1115#[cfg(test)]
1116mod recovery_tests;