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