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        let mut handover = None;
232        loop {
233            match self
234                .run_process_segment_with_scoped_effect_controller(
235                    registration.clone(),
236                    execution_context.clone(),
237                    scoped_effect_controller.clone(),
238                    cancellation.clone(),
239                    handover,
240                )
241                .await?
242            {
243                crate::ProcessRunOutcome::Terminal(output) => return Ok(*output),
244                crate::ProcessRunOutcome::SegmentBoundary(next) => handover = Some(next),
245            }
246        }
247    }
248
249    /// Run exactly one engine segment. Durable substrates use this method so a
250    /// non-terminal boundary can end the current substrate invocation; inline
251    /// callers continue to use the looping `run_process_with_*` methods.
252    pub async fn run_process_segment_with_scoped_effect_controller(
253        &self,
254        registration: ProcessRegistration,
255        execution_context: ProcessExecutionContext,
256        scoped_effect_controller: crate::ScopedEffectController<'_>,
257        cancellation: CancellationToken,
258        handover: Option<crate::SegmentHandover>,
259    ) -> Result<crate::ProcessRunOutcome, PluginError> {
260        self.ensure_stable_process_id(&registration)?;
261        self.ensure_durable_store_facets()?;
262        // Externally-owned rows are never executed by lash (ADR 0019). Reject the
263        // disposition before touching a runtime — the old fabricated-success path
264        // for External inputs is deleted.
265        if registration.disposition == RecoveryDisposition::ExternallyOwned {
266            return Err(PluginError::Session(format!(
267                "process `{}` is externally-owned and must not be executed by lash",
268                registration.id
269            )));
270        }
271        // Durable, lease-fenced "execution started" fact: recorded immediately
272        // before executing so a later sweep can distinguish a started OwnerBound
273        // row (never re-run) from an unstarted one (runnable by anyone). This is
274        // the shared run path both the inline sweep and the Restate run handler
275        // funnel through. First-writer-wins, so a re-invocation is a no-op.
276        self.config
277            .process_registry
278            .record_first_started(
279                &registration.id,
280                crate::ProcessStarted {
281                    owner: self.config.lease_owner.clone(),
282                    started_at_ms: self.now_ms(),
283                },
284            )
285            .await?;
286        let mut runtime = self.runtime_for_registration(&registration).await?;
287        let originator_scope = if let crate::ProcessOriginator::Session { scope } =
288            &registration.provenance.originator
289        {
290            Some(scope)
291        } else {
292            None
293        };
294        let probe_scope = registration.wake_target.as_ref().or(originator_scope);
295        if let Some(probe) =
296            probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
297        {
298            runtime.set_turn_phase_probe(probe);
299        }
300        let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
301            PluginError::Session(format!(
302                "failed to build runtime env for process `{}`: {err}",
303                registration.id
304            ))
305        })?;
306        Ok(manager
307            .run_process(
308                registration,
309                execution_context,
310                Arc::clone(&self.config.process_registry),
311                scoped_effect_controller,
312                cancellation,
313                handover,
314            )
315            .await)
316    }
317
318    /// Sweep the registry for non-terminal processes and re-execute the ones
319    /// this worker can claim, driving each to a terminal state.
320    ///
321    /// This is the crash-recovery counterpart to a worker that ran a process
322    /// from a live turn: a trigger/trigger-started process whose worker
323    /// died mid-flight is left non-terminal in the registry, and a subsequent
324    /// worker reopening that registry must finish it. The sweep:
325    ///
326    /// 1. lists every non-terminal process ([`ProcessRegistry::list_non_terminal`]);
327    /// 2. claims the durable single-owner [`ProcessLease`] over each — a process
328    ///    already leased live by *another* owner is skipped (it is being run by
329    ///    that owner right now) unless persisted liveness metadata proves that
330    ///    owner definitely dead, in which case the lease is reclaimed with the
331    ///    fenced CAS discipline of
332    ///    [`ProcessRegistry::reclaim_process_lease`]; either way a non-terminal
333    ///    process is re-run by exactly one owner (lease fencing);
334    /// 3. runs the claimed process on this worker's wired controller, renewing
335    ///    the lease across the long-running execution so a healthy recovery is
336    ///    not swept out from under itself;
337    /// 4. atomically writes the terminal outcome and releases the validated lease.
338    ///
339    /// Idempotent by `process_id`: terminal processes are never in the worklist,
340    /// and a process that became terminal between the list and the claim is
341    /// detected after claiming and skipped, so re-running a recovery sweep does
342    /// not double-execute completed work.
343    pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
344        self.reconcile_trigger_deliveries().await?;
345        let records = self.config.process_registry.list_non_terminal().await?;
346        for record in records {
347            // Run each claimed process on its OWN lease-fenced task. A sequential
348            // drive that awaited each process to terminal would deadlock a process
349            // that blocks awaiting a nested child (`start child` then `await`, or a
350            // subagent fan-out): the one drive task would park inside the parent's
351            // await and never claim the child. Spawning frees the loop so a
352            // subsequent host-driven pass claims and runs the child, and the
353            // per-process `ProcessLease` fences concurrent owners — so spawning a
354            // task per pending row on every drive is idempotent (a row already
355            // running is skipped on claim conflict) and one failing row never
356            // aborts the rest of the sweep.
357            let worker = self.clone();
358            tokio::spawn(async move { worker.recover_process(record).await });
359        }
360        Ok(())
361    }
362
363    async fn reconcile_trigger_deliveries(&self) -> Result<(), PluginError> {
364        let subscriptions = self
365            .config
366            .trigger_store
367            .list_subscriptions(crate::TriggerSubscriptionFilter::default())
368            .await?;
369        if subscriptions.is_empty() {
370            return Ok(());
371        }
372        let router = crate::TriggerRouter::new(
373            Arc::clone(&self.config.trigger_store),
374            Some(Arc::clone(&self.config.process_registry)),
375            self.config.process_work_driver.clone(),
376        );
377        let mut seen = BTreeSet::new();
378        let mut started_any = false;
379        for subscription in subscriptions {
380            let deliveries = self
381                .config
382                .trigger_store
383                .list_deliveries_by_subscription_id(&subscription.subscription_id)
384                .await?;
385            for delivery in deliveries {
386                let delivery_key = (
387                    delivery.occurrence.occurrence_id.clone(),
388                    delivery.subscription.subscription_id.clone(),
389                );
390                if !seen.insert(delivery_key) {
391                    continue;
392                }
393                if self
394                    .config
395                    .process_registry
396                    .get_process(&delivery.process_id)
397                    .await
398                    .is_some()
399                {
400                    continue;
401                }
402                let Some(scoped_effect_controller) = self
403                    .config
404                    .runtime_host
405                    .control
406                    .effect_host
407                    .scoped_static(crate::ExecutionScope::runtime_operation(format!(
408                        "trigger-delivery-reconcile:{}",
409                        delivery.process_id
410                    )))
411                    .map_err(|err| PluginError::Session(err.to_string()))?
412                else {
413                    return Err(PluginError::Session(
414                        "process worker effect host must provide a static trigger delivery reconcile scope"
415                            .to_string(),
416                    ));
417                };
418                match router
419                    .start_delivery(
420                        &delivery,
421                        Arc::clone(&self.config.process_registry),
422                        scoped_effect_controller.controller(),
423                    )
424                    .await
425                {
426                    Ok(()) => started_any = true,
427                    Err(err) => tracing::warn!(
428                        process_id = %delivery.process_id,
429                        occurrence_id = %delivery.occurrence.occurrence_id,
430                        subscription_id = %delivery.subscription.subscription_id,
431                        error = %err,
432                        "failed to reconcile trigger delivery",
433                    ),
434                }
435            }
436        }
437        if started_any && let Some(driver) = self.config.process_work_driver.as_ref() {
438            driver
439                .claim_and_run_pending("trigger_delivery_reconcile")
440                .await?;
441        }
442        Ok(())
443    }
444
445    /// Graceful owner drain: terminalize this host's own started `OwnerBound`
446    /// work as `Abandoned{OwnerDrain}` at close (ADR 0019).
447    ///
448    /// This is an explicit **host lever on the worker**, never an implicit
449    /// consequence of closing a session. Processes are global and outlive any
450    /// one session ([ADR 0011]), so `LashSession::close`/`park` must not touch
451    /// them; a host that wants its in-flight owner-bound work terminalized at
452    /// shutdown calls this on the worker it is tearing down.
453    ///
454    /// Drain sequence (the operations runbook owns the surrounding steps; this
455    /// is the terminal-writing step):
456    /// 1. stop admitting new work to this worker;
457    /// 2. cancel or await the worker's in-flight run tasks so they release their
458    ///    per-run leases — for **Rerunnable** in-flight work that is the whole
459    ///    story: stopping the local run task without any terminal write leaves
460    ///    the row non-terminal so the next worker re-runs it (its contract);
461    /// 3. call this lever: for every non-terminal **OwnerBound** row this exact
462    ///    worker started (`first_started.owner == self.config.lease_owner`),
463    ///    claim a fresh drain lease and, being the owner completing its own
464    ///    work, write `Abandoned{OwnerDrain}` under it — the ordinary graceful
465    ///    completion path, respecting the single-writer rule.
466    ///
467    /// A row still held by a live foreign lease (an in-flight run under one of
468    /// this worker's own recovery incarnations that step 2 has not yet released)
469    /// is deferred rather than reclaimed, so the drain never races a still-live
470    /// run; such a row reaches `Abandoned` on the next drain pass or at a peer's
471    /// recovery sweep. Rows started by a different owner, not-yet-started
472    /// OwnerBound rows (still claimable by anyone), Rerunnable rows, and
473    /// Externally-Owned rows are all left untouched.
474    ///
475    /// [ADR 0011]: durable process registration is session-independent.
476    pub async fn drain_owner_bound_work(&self) -> Result<ProcessDrainReport, PluginError> {
477        let mut abandoned = Vec::new();
478        for record in self.config.process_registry.list_non_terminal().await? {
479            if record.disposition != RecoveryDisposition::OwnerBound {
480                continue;
481            }
482            let Some(first_started) = record.first_started.as_ref() else {
483                // Never started: first execution is not re-execution, so any
484                // worker may still claim it. Draining it would strand runnable
485                // work as Abandoned.
486                continue;
487            };
488            if first_started.owner != self.config.lease_owner {
489                // Started by a different owner; not this host's to drain.
490                continue;
491            }
492            let owner = first_started.owner.clone();
493            if self.drain_one_owner_bound(&record.id, owner).await {
494                abandoned.push(record.id);
495            }
496        }
497        Ok(ProcessDrainReport { abandoned })
498    }
499
500    /// Terminalize one of this host's started OwnerBound rows as
501    /// `Abandoned{OwnerDrain}` under a freshly claimed drain lease. Returns
502    /// whether the terminal was written (`false` when the row is held by a live
503    /// foreign lease, already terminal, or the claim failed).
504    async fn drain_one_owner_bound(
505        &self,
506        process_id: &str,
507        owner: crate::LeaseOwnerIdentity,
508    ) -> bool {
509        let lease_ttl_ms = self.lease_timings().ttl_ms();
510        let drain_owner = self.recovery_lease_owner();
511        let lease = match self
512            .config
513            .process_registry
514            .claim_process_lease(process_id, &drain_owner, lease_ttl_ms)
515            .await
516        {
517            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
518            // A live run still holds the lease, or the claim failed: defer.
519            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return false,
520        };
521        if self
522            .config
523            .process_registry
524            .get_process(process_id)
525            .await
526            .is_some_and(|current| current.is_terminal())
527        {
528            self.release_or_log(&lease).await;
529            return false;
530        }
531        let evidence = AbandonEvidence {
532            writer: AbandonWriter::OwnerDrain,
533            owner: Some(owner),
534            epoch_ms: self.now_ms(),
535        };
536        self.complete_and_release(
537            &lease,
538            process_id,
539            ProcessAwaitOutput::Abandoned {
540                evidence: Box::new(evidence),
541                control: None,
542            },
543        )
544        .await;
545        true
546    }
547
548    /// Unique lease owner for one recovery attempt.
549    ///
550    /// Derived from [`DurableProcessWorkerConfig::lease_owner`]: a fresh
551    /// `(owner_id, incarnation_id)` per attempt keeps sweeps idempotent (a
552    /// still-running attempt's live lease fences later passes instead of being
553    /// re-entered as "own lease"), while the configured liveness metadata is
554    /// inherited so peers can prove a crashed worker dead and reclaim.
555    fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
556        let attempt = uuid::Uuid::new_v4();
557        crate::LeaseOwnerIdentity {
558            owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
559            incarnation_id: attempt.to_string(),
560            liveness: self.config.lease_owner.liveness.clone(),
561        }
562    }
563
564    /// Recover one non-terminal row, obeying its declared recovery disposition
565    /// (ADR 0019). The verdict per disposition:
566    ///
567    /// - **ExternallyOwned**: never claimed, never run. If a pending Abandon
568    ///   Request is present it is reconciled into `Abandoned{reconciled_request}`.
569    /// - **Rerunnable**: exactly today's behavior — claim, (re-)run, complete.
570    /// - **OwnerBound, never started**: any worker may run it (first execution is
571    ///   not re-execution); the runner records `first_started` before executing.
572    /// - **OwnerBound, started**: never re-run. A provably-dead holder yields
573    ///   `Abandoned{sweep}`; a merely silent/expired holder is left non-terminal
574    ///   unless an Abandon Request is present and the lease has lapsed, which
575    ///   yields `Abandoned{reconciled_request}`. Elapsed time alone never
576    ///   terminalizes.
577    ///
578    /// Every Abandoned write goes through `complete_process_with_lease`, which
579    /// atomically validates this sweep's fence, appends the terminal, and clears
580    /// the lease so a revenant's stale token is rejected.
581    async fn recover_process(&self, record: ProcessRecord) {
582        let process_id = record.id.clone();
583        // ExternallyOwned: lash never executes the row. The only recovery action
584        // is reconciling a pending Abandon Request; there is no owner lease to
585        // wait out.
586        if record.disposition == RecoveryDisposition::ExternallyOwned {
587            if record.abandon_request.is_some() {
588                self.reconcile_externally_owned_abandon(&process_id).await;
589            }
590            return;
591        }
592
593        let lease_ttl_ms = self.lease_timings().ttl_ms();
594        let owner = self.recovery_lease_owner();
595        // Claim the single-owner lease, distinguishing a fenced reclaim of a
596        // provably-dead holder (death evidence) from acquiring a free/expired
597        // lease (no death evidence). A live, not-provably-dead holder or a claim
598        // error leaves the row to its owner.
599        let Some((lease, dead_holder)) = self
600            .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
601            .await
602        else {
603            return;
604        };
605        // Terminal between the list and the claim. Idempotent by process_id: do
606        // not re-execute or re-terminalize a finished process.
607        if self
608            .config
609            .process_registry
610            .get_process(&process_id)
611            .await
612            .is_some_and(|current| current.is_terminal())
613        {
614            self.release_or_log(&lease).await;
615            return;
616        }
617
618        match record.disposition {
619            // Rerunnable: claim, (re-)run, complete — exactly today's behavior.
620            RecoveryDisposition::Rerunnable => self.run_and_complete(record, lease).await,
621            RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
622                // Started OwnerBound work is NEVER re-run — abandonment is the
623                // only recovery. `first_started`'s owner is the lapsed owner the
624                // reconciled-request evidence names.
625                let lapsed_owner = record
626                    .first_started
627                    .as_ref()
628                    .map(|started| started.owner.clone());
629                let evidence = if let Some(dead_holder) = dead_holder {
630                    // Holder provably dead ⇒ Abandoned{sweep}.
631                    Some(AbandonEvidence {
632                        writer: AbandonWriter::Sweep,
633                        owner: Some(dead_holder.owner),
634                        epoch_ms: self.now_ms(),
635                    })
636                } else if record.abandon_request.is_some() {
637                    // Silent/expired holder without death evidence, but an
638                    // operator authorized abandonment and the lease has lapsed
639                    // (we acquired a free/expired lease) ⇒ Abandoned{reconciled}.
640                    Some(AbandonEvidence {
641                        writer: AbandonWriter::ReconciledRequest,
642                        owner: lapsed_owner,
643                        epoch_ms: self.now_ms(),
644                    })
645                } else {
646                    // No death evidence and no authorization: elapsed time alone
647                    // never terminalizes. Leave the row non-terminal.
648                    None
649                };
650                match evidence {
651                    Some(evidence) => {
652                        self.complete_and_release(
653                            &lease,
654                            &process_id,
655                            ProcessAwaitOutput::Abandoned {
656                                evidence: Box::new(evidence),
657                                control: None,
658                            },
659                        )
660                        .await;
661                    }
662                    None => self.release_or_log(&lease).await,
663                }
664            }
665            // OwnerBound, never started: first execution is not re-execution, so
666            // any worker may run it; the runner records first_started first.
667            RecoveryDisposition::OwnerBound => self.run_and_complete(record, lease).await,
668            // Filtered above; releasing keeps the lease honest if reached.
669            RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
670        }
671    }
672
673    /// Wall-clock epoch ms from the worker's configured clock.
674    fn now_ms(&self) -> u64 {
675        self.config.runtime_host.clock.timestamp_ms()
676    }
677
678    /// Claim the recovery lease. Returns the acquired lease plus, when the claim
679    /// fenced out a provably-dead holder, that holder as death evidence. Returns
680    /// `None` when the row is held by a live (not provably-dead) owner or the
681    /// claim fails — either way this pass leaves the row to its owner.
682    async fn claim_for_recovery(
683        &self,
684        process_id: &str,
685        owner: &crate::LeaseOwnerIdentity,
686        lease_ttl_ms: u64,
687    ) -> Option<(ProcessLease, Option<ProcessLease>)> {
688        match self
689            .config
690            .process_registry
691            .claim_process_lease(process_id, owner, lease_ttl_ms)
692            .await
693        {
694            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => Some((lease, None)),
695            Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
696                if holder.owner.is_definitely_dead_for_claimant(owner) =>
697            {
698                match self
699                    .config
700                    .process_registry
701                    .reclaim_process_lease(process_id, owner, &holder, lease_ttl_ms)
702                    .await
703                {
704                    Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => {
705                        Some((lease, Some(holder)))
706                    }
707                    Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
708                }
709            }
710            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
711        }
712    }
713
714    /// Reconcile a pending Abandon Request on an externally-owned row into an
715    /// `Abandoned{reconciled_request}` terminal. Lash never executed the row, so
716    /// there is no owner lease to wait out — but the sweep claims its own lease
717    /// and completes through the atomic fenced path so it stays the single writer.
718    async fn reconcile_externally_owned_abandon(&self, process_id: &str) {
719        let lease_ttl_ms = self.lease_timings().ttl_ms();
720        let owner = self.recovery_lease_owner();
721        let lease = match self
722            .config
723            .process_registry
724            .claim_process_lease(process_id, &owner, lease_ttl_ms)
725            .await
726        {
727            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
728            // A concurrent writer holds the lease; let it finish.
729            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
730        };
731        if self
732            .config
733            .process_registry
734            .get_process(process_id)
735            .await
736            .is_some_and(|current| current.is_terminal())
737        {
738            self.release_or_log(&lease).await;
739            return;
740        }
741        let evidence = AbandonEvidence {
742            writer: AbandonWriter::ReconciledRequest,
743            // Externally-owned work has no lash execution owner to name.
744            owner: None,
745            epoch_ms: self.now_ms(),
746        };
747        self.complete_and_release(
748            &lease,
749            process_id,
750            ProcessAwaitOutput::Abandoned {
751                evidence: Box::new(evidence),
752                control: None,
753            },
754        )
755        .await;
756    }
757
758    /// (Re-)run a claimed row under its renewed lease and write the terminal
759    /// outcome, the same live-owner-is-single-writer path used before ADR 0019.
760    async fn run_and_complete(&self, record: ProcessRecord, lease: ProcessLease) {
761        let process_id = record.id.clone();
762        let registration = registration_from_record(record);
763        let execution_context = ProcessExecutionContext::default();
764        let mut handover = None;
765        loop {
766            match self
767                .run_process_with_lease_renewal(
768                    registration.clone(),
769                    execution_context.clone(),
770                    lease.clone(),
771                    handover,
772                )
773                .await
774            {
775                // Ran to a terminal outcome (success or a process-level failure) while
776                // holding the lease: this owner is the single writer of the terminal.
777                Ok(crate::ProcessRunOutcome::Terminal(output)) => {
778                    self.complete_and_release(&lease, &process_id, *output)
779                        .await;
780                    return;
781                }
782                Ok(crate::ProcessRunOutcome::SegmentBoundary(next)) => {
783                    tracing::debug!(
784                        process_id = %process_id,
785                        reason = ?next.reason,
786                        "process crossed an in-memory segment boundary",
787                    );
788                    handover = Some(next);
789                }
790                // The lease was lost mid-run — another owner reclaimed the expired
791                // lease and is now running this process. Do NOT write a terminal
792                // outcome or release the lease: that would race the new owner and
793                // could record a succeeded process as Failed. Leave the row to the
794                // lease holder; it will finish (or another sweep retries it).
795                Err(RecoverFailure::LeaseLost(err)) => {
796                    tracing::warn!(
797                        process_id = %process_id,
798                        error = %err,
799                        "process recovery lost its lease mid-run; deferring to the new owner",
800                    );
801                    return;
802                }
803                // The process could not be run at all (rebuild/store-facet failure):
804                // terminalize as a recovery failure so the row leaves the worklist.
805                Err(RecoverFailure::Run(err)) => {
806                    let output = ProcessAwaitOutput::Failure {
807                        class: crate::ToolFailureClass::Execution,
808                        code: "process_recovery_failed".to_string(),
809                        message: err.to_string(),
810                        raw: None,
811                        control: None,
812                    };
813                    self.complete_and_release(&lease, &process_id, output).await;
814                    return;
815                }
816            }
817        }
818    }
819
820    /// Write a recovered process's terminal outcome and release its lease in one
821    /// atomic fenced registry operation.
822    async fn complete_and_release(
823        &self,
824        lease: &ProcessLease,
825        process_id: &str,
826        output: ProcessAwaitOutput,
827    ) {
828        // Refresh first so a healthy long-running worker has a full completion
829        // window. The registry then validates this exact fence, appends the
830        // terminal event, and clears the lease in one transaction. There is no
831        // renew-then-unfenced-write gap for a stalled worker to cross.
832        let fenced = match self
833            .config
834            .process_registry
835            .renew_process_lease(lease, self.lease_timings().ttl_ms())
836            .await
837        {
838            Ok(renewed) => renewed,
839            Err(err) => {
840                tracing::warn!(
841                    process_id = %process_id,
842                    error = %err,
843                    "lost process lease before terminal write; deferring to the new owner",
844                );
845                return;
846            }
847        };
848        if let Err(err) = self
849            .config
850            .process_registry
851            .complete_process_with_lease(&fenced, output)
852            .await
853        {
854            tracing::warn!(
855                process_id = %process_id,
856                error = %err,
857                "failed to write recovered process terminal outcome",
858            );
859        }
860    }
861
862    async fn release_or_log(&self, lease: &ProcessLease) {
863        if let Err(err) = self.release_process_lease(lease).await {
864            tracing::warn!(
865                process_id = %lease.process_id,
866                error = %err,
867                "failed to release recovered process lease",
868            );
869        }
870    }
871
872    /// Run a recovered process while renewing its lease across the execution,
873    /// mirroring the turn-lease renewal that keeps a long-running effect's lease
874    /// from expiring under the live owner.
875    async fn run_process_with_lease_renewal(
876        &self,
877        registration: ProcessRegistration,
878        execution_context: ProcessExecutionContext,
879        mut lease: ProcessLease,
880        handover: Option<crate::SegmentHandover>,
881    ) -> Result<crate::ProcessRunOutcome, RecoverFailure> {
882        let process_id = registration.id.clone();
883        let cancellation = CancellationToken::new();
884        let cancel_watcher = {
885            let awaiter = self
886                .config
887                .process_change_hub
888                .clone()
889                .map(|hub| {
890                    crate::ProcessAwaiter::new(Arc::clone(&self.config.process_registry), hub)
891                })
892                .unwrap_or_else(|| {
893                    crate::ProcessAwaiter::polling(Arc::clone(&self.config.process_registry))
894                });
895            let process_id = process_id.clone();
896            let cancellation = cancellation.clone();
897            tokio::spawn(async move {
898                match awaiter
899                    .await_event(&process_id, "process.cancel_requested", 0)
900                    .await
901                {
902                    Ok(_) => cancellation.cancel(),
903                    Err(err) => tracing::warn!(
904                        process_id = %process_id,
905                        error = %err,
906                        "process cancel watcher stopped before observing cancellation",
907                    ),
908                }
909            })
910        };
911        let scoped_effect_controller = self
912            .config
913            .runtime_host
914            .control
915            .effect_host
916            .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
917            .map_err(|err| RecoverFailure::Run(PluginError::Session(err.to_string())))?
918            .ok_or_else(|| {
919                RecoverFailure::Run(PluginError::Session(
920                    "process worker effect host must provide a static process scope".to_string(),
921                ))
922            })?;
923        let pending = self.run_process_segment_with_scoped_effect_controller(
924            registration,
925            execution_context,
926            scoped_effect_controller,
927            cancellation.clone(),
928            handover,
929        );
930        tokio::pin!(pending);
931        loop {
932            tokio::select! {
933                outcome = &mut pending => {
934                    cancel_watcher.abort();
935                    return outcome.map_err(RecoverFailure::Run);
936                }
937                _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
938                    match self
939                        .config
940                        .process_registry
941                        .renew_process_lease(&lease, self.lease_timings().ttl_ms())
942                        .await
943                    {
944                        Ok(renewed) => lease = renewed,
945                        Err(err) => {
946                            cancellation.cancel();
947                            cancel_watcher.abort();
948                            return Err(RecoverFailure::LeaseLost(err));
949                        }
950                    }
951                }
952            }
953        }
954    }
955
956    fn lease_timings(&self) -> crate::LeaseTimings {
957        self.config.runtime_host.control.lease_timings
958    }
959
960    async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
961        self.config
962            .process_registry
963            .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
964            .await
965    }
966
967    pub async fn request_process_cancel(
968        &self,
969        process_id: &str,
970        reason: Option<String>,
971    ) -> Result<(), PluginError> {
972        self.config
973            .process_registry
974            .append_event(
975                process_id,
976                crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
977            )
978            .await
979            .map(|_| ())
980    }
981
982    async fn runtime_for_registration(
983        &self,
984        registration: &ProcessRegistration,
985    ) -> Result<LashRuntime, PluginError> {
986        match registration.input.as_ref() {
987            ProcessInput::SessionTurn { create_request, .. } => {
988                self.runtime_for_session_turn(registration, create_request.as_ref())
989                    .await
990            }
991            ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
992                self.runtime_for_process_env(registration).await
993            }
994            // Externally-owned rows are rejected before dispatch (ADR 0019), so an
995            // External input has no execution runtime; fail loudly rather than
996            // fabricate one.
997            ProcessInput::External { .. } => Err(PluginError::Session(format!(
998                "process `{}` is externally-owned and has no execution runtime",
999                registration.id
1000            ))),
1001        }
1002    }
1003
1004    async fn runtime_for_session_turn(
1005        &self,
1006        registration: &ProcessRegistration,
1007        create_request: &crate::SessionCreateRequest,
1008    ) -> Result<LashRuntime, PluginError> {
1009        let mut policy = create_request
1010            .policy
1011            .clone()
1012            .unwrap_or_else(|| self.config.session_policy.clone());
1013        if policy.recorded_provider_id().is_empty() {
1014            policy.provider_id = self.config.session_policy.provider_id.clone();
1015        }
1016        self.build_ephemeral_runtime(
1017            format!("process-session-turn:{}", registration.id),
1018            policy,
1019            create_request.plugin_options.clone(),
1020            "session turn request",
1021        )
1022        .await
1023    }
1024
1025    async fn runtime_for_process_env(
1026        &self,
1027        registration: &ProcessRegistration,
1028    ) -> Result<LashRuntime, PluginError> {
1029        let Some(env_ref) = registration.env_ref.as_ref() else {
1030            return Err(PluginError::Session(format!(
1031                "process `{}` is missing a captured execution env",
1032                registration.id
1033            )));
1034        };
1035        let env = crate::load_process_execution_env(
1036            self.config
1037                .runtime_host
1038                .durability
1039                .process_env_store
1040                .as_ref(),
1041            env_ref,
1042        )
1043        .await?;
1044        self.build_ephemeral_runtime(
1045            format!("process-env:{}", registration.id),
1046            env.policy,
1047            env.plugin_options,
1048            env_ref.as_str(),
1049        )
1050        .await
1051    }
1052
1053    async fn build_ephemeral_runtime(
1054        &self,
1055        session_id: String,
1056        policy: crate::SessionPolicy,
1057        plugin_options: crate::PluginOptions,
1058        source_label: &str,
1059    ) -> Result<LashRuntime, PluginError> {
1060        let store = Arc::new(InMemorySessionStore::default());
1061        let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
1062            if let Some(hub) = self.config.process_change_hub.clone() {
1063                ProcessWorkDriver::from_watched(
1064                    Arc::clone(&self.config.process_registry),
1065                    hub,
1066                    Arc::new(crate::InlineProcessRunHandle::new(self.clone())),
1067                )
1068            } else {
1069                ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
1070            }
1071        });
1072        let mut builder = EmbeddedRuntimeBuilder::new()
1073            .with_session_id(session_id.to_string())
1074            .with_plugin_host(self.config.plugin_host.as_ref().clone())
1075            .with_runtime_host(self.config.runtime_host.clone())
1076            .with_policy(policy)
1077            .with_plugin_options(plugin_options)
1078            .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
1079            .with_trigger_store(Arc::clone(&self.config.trigger_store))
1080            .with_process_registry(Arc::clone(&self.config.process_registry))
1081            .with_process_work_driver(process_work_driver)
1082            .with_residency(self.config.residency)
1083            .with_store(store);
1084        if let Some(driver) = self.config.queued_work_driver.clone() {
1085            builder = builder.with_queued_work_driver(driver);
1086        }
1087        builder.build().await.map_err(|err| {
1088            PluginError::Session(format!(
1089                "failed to build process worker runtime for {source_label}: {err}"
1090            ))
1091        })
1092    }
1093
1094    /// Enforce the durable-first wiring invariant at the worker process-run
1095    /// boundary: when the worker was wired with a durable effect host, every
1096    /// store it will execute against must also be durable. A durable host
1097    /// running against any ephemeral store fails loudly here rather than
1098    /// silently re-executing a process against non-durable state.
1099    ///
1100    /// Inline controllers (the default tier) impose no requirement, so
1101    /// inline/in-memory workers pass unchanged.
1102    fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
1103        if self
1104            .config
1105            .runtime_host
1106            .control
1107            .effect_host
1108            .durability_tier()
1109            != crate::DurabilityTier::Durable
1110        {
1111            return Ok(());
1112        }
1113        let require = |facet: crate::DurableStoreFacet| {
1114            PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
1115        };
1116        if self
1117            .config
1118            .runtime_host
1119            .durability
1120            .attachment_store
1121            .persistence()
1122            .durability_tier()
1123            != crate::DurabilityTier::Durable
1124        {
1125            return Err(require(crate::DurableStoreFacet::AttachmentStore));
1126        }
1127        if self
1128            .config
1129            .runtime_host
1130            .durability
1131            .process_env_store
1132            .durability_tier()
1133            != crate::DurabilityTier::Durable
1134        {
1135            return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
1136        }
1137        if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
1138            return Err(require(crate::DurableStoreFacet::SessionStore));
1139        }
1140        if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
1141            return Err(require(crate::DurableStoreFacet::ProcessRegistry));
1142        }
1143        if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
1144            return Err(require(crate::DurableStoreFacet::TriggerStore));
1145        }
1146        Ok(())
1147    }
1148
1149    /// Enforce the stable-process-id invariant at every (re-)execution: process
1150    /// execution identity is the persisted `process_id`, so a retry — a Restate
1151    /// `run` re-invocation (keyed `LashProcessWorkflow/{process_id}`) or a
1152    /// recovery sweep re-running a non-terminal row — must present that stable
1153    /// id. An empty/fresh id has lost its idempotency anchor and is rejected
1154    /// loudly here, mirroring how `ExecutionScope` rejects an
1155    /// empty turn id at the durable-effect boundary.
1156    fn ensure_stable_process_id(
1157        &self,
1158        registration: &ProcessRegistration,
1159    ) -> Result<(), PluginError> {
1160        if registration.id.trim().is_empty() {
1161            return Err(PluginError::Session(
1162                crate::RuntimeError::missing_process_execution_id().to_string(),
1163            ));
1164        }
1165        Ok(())
1166    }
1167}
1168
1169/// Rebuild a runnable registration from a persisted row, preserving its declared
1170/// disposition (ADR 0019).
1171fn registration_from_record(record: ProcessRecord) -> ProcessRegistration {
1172    ProcessRegistration {
1173        id: record.id,
1174        input: record.input,
1175        disposition: record.disposition,
1176        identity: record.identity,
1177        event_types: record.event_types,
1178        provenance: record.provenance,
1179        env_ref: record.env_ref,
1180        wake_target: record.wake_target,
1181    }
1182}
1183
1184#[cfg(test)]
1185mod boundary_tests;
1186#[cfg(test)]
1187mod recovery_tests;