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#[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 pub residency: crate::Residency,
40 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 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#[derive(Clone)]
159pub struct DurableProcessWorker {
160 config: Arc<DurableProcessWorkerConfig>,
161}
162
163#[derive(Clone, Debug, Default, PartialEq, Eq)]
165pub struct ProcessDrainReport {
166 pub abandoned: Vec<String>,
169}
170
171enum RecoverFailure {
173 LeaseLost(PluginError),
177 Run(PluginError),
180}
181
182impl DurableProcessWorker {
183 pub fn new(config: DurableProcessWorkerConfig) -> Self {
184 Self {
185 config: Arc::new(config),
186 }
187 }
188
189 pub fn from_shared_config(config: Arc<DurableProcessWorkerConfig>) -> Self {
190 Self { config }
191 }
192
193 pub fn config(&self) -> &DurableProcessWorkerConfig {
194 &self.config
195 }
196
197 pub async fn run_process(
198 &self,
199 registration: ProcessRegistration,
200 execution_context: ProcessExecutionContext,
201 cancellation: CancellationToken,
202 ) -> Result<ProcessAwaitOutput, PluginError> {
203 let scoped_effect_controller = self
204 .config
205 .runtime_host
206 .control
207 .effect_host
208 .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
209 .map_err(|err| PluginError::Session(err.to_string()))?
210 .ok_or_else(|| {
211 PluginError::Session(
212 "process worker effect host must provide a static process scope".to_string(),
213 )
214 })?;
215 self.run_process_with_scoped_effect_controller(
216 registration,
217 execution_context,
218 scoped_effect_controller,
219 cancellation,
220 )
221 .await
222 }
223
224 pub async fn run_process_with_scoped_effect_controller(
225 &self,
226 registration: ProcessRegistration,
227 execution_context: ProcessExecutionContext,
228 scoped_effect_controller: crate::ScopedEffectController<'_>,
229 cancellation: CancellationToken,
230 ) -> Result<ProcessAwaitOutput, PluginError> {
231 self.ensure_stable_process_id(®istration)?;
232 self.ensure_durable_store_facets()?;
233 if registration.disposition == RecoveryDisposition::ExternallyOwned {
237 return Err(PluginError::Session(format!(
238 "process `{}` is externally-owned and must not be executed by lash",
239 registration.id
240 )));
241 }
242 self.config
248 .process_registry
249 .record_first_started(
250 ®istration.id,
251 crate::ProcessStarted {
252 owner: self.config.lease_owner.clone(),
253 started_at_ms: self.now_ms(),
254 },
255 )
256 .await?;
257 let mut runtime = self.runtime_for_registration(®istration).await?;
258 let originator_scope = if let crate::ProcessOriginator::Session { scope } =
259 ®istration.provenance.originator
260 {
261 Some(scope)
262 } else {
263 None
264 };
265 let probe_scope = registration.wake_target.as_ref().or(originator_scope);
266 if let Some(probe) =
267 probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
268 {
269 runtime.set_turn_phase_probe(probe);
270 }
271 let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
272 PluginError::Session(format!(
273 "failed to build runtime env for process `{}`: {err}",
274 registration.id
275 ))
276 })?;
277 Ok(manager
278 .run_process(
279 registration,
280 execution_context,
281 Arc::clone(&self.config.process_registry),
282 scoped_effect_controller,
283 cancellation,
284 )
285 .await)
286 }
287
288 pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
314 self.reconcile_trigger_deliveries().await?;
315 let records = self.config.process_registry.list_non_terminal().await?;
316 for record in records {
317 let worker = self.clone();
328 tokio::spawn(async move { worker.recover_process(record).await });
329 }
330 Ok(())
331 }
332
333 async fn reconcile_trigger_deliveries(&self) -> Result<(), PluginError> {
334 let subscriptions = self
335 .config
336 .trigger_store
337 .list_subscriptions(crate::TriggerSubscriptionFilter::default())
338 .await?;
339 if subscriptions.is_empty() {
340 return Ok(());
341 }
342 let router = crate::TriggerRouter::new(
343 Arc::clone(&self.config.trigger_store),
344 Some(Arc::clone(&self.config.process_registry)),
345 self.config.process_work_driver.clone(),
346 );
347 let mut seen = BTreeSet::new();
348 let mut started_any = false;
349 for subscription in subscriptions {
350 let deliveries = self
351 .config
352 .trigger_store
353 .list_deliveries_by_subscription_id(&subscription.subscription_id)
354 .await?;
355 for delivery in deliveries {
356 let delivery_key = (
357 delivery.occurrence.occurrence_id.clone(),
358 delivery.subscription.subscription_id.clone(),
359 );
360 if !seen.insert(delivery_key) {
361 continue;
362 }
363 if self
364 .config
365 .process_registry
366 .get_process(&delivery.process_id)
367 .await
368 .is_some()
369 {
370 continue;
371 }
372 let Some(scoped_effect_controller) = self
373 .config
374 .runtime_host
375 .control
376 .effect_host
377 .scoped_static(crate::ExecutionScope::runtime_operation(format!(
378 "trigger-delivery-reconcile:{}",
379 delivery.process_id
380 )))
381 .map_err(|err| PluginError::Session(err.to_string()))?
382 else {
383 return Err(PluginError::Session(
384 "process worker effect host must provide a static trigger delivery reconcile scope"
385 .to_string(),
386 ));
387 };
388 match router
389 .start_delivery(
390 &delivery,
391 Arc::clone(&self.config.process_registry),
392 scoped_effect_controller.controller(),
393 )
394 .await
395 {
396 Ok(()) => started_any = true,
397 Err(err) => tracing::warn!(
398 process_id = %delivery.process_id,
399 occurrence_id = %delivery.occurrence.occurrence_id,
400 subscription_id = %delivery.subscription.subscription_id,
401 error = %err,
402 "failed to reconcile trigger delivery",
403 ),
404 }
405 }
406 }
407 if started_any && let Some(driver) = self.config.process_work_driver.as_ref() {
408 driver
409 .claim_and_run_pending("trigger_delivery_reconcile")
410 .await?;
411 }
412 Ok(())
413 }
414
415 pub async fn drain_owner_bound_work(&self) -> Result<ProcessDrainReport, PluginError> {
447 let mut abandoned = Vec::new();
448 for record in self.config.process_registry.list_non_terminal().await? {
449 if record.disposition != RecoveryDisposition::OwnerBound {
450 continue;
451 }
452 let Some(first_started) = record.first_started.as_ref() else {
453 continue;
457 };
458 if first_started.owner != self.config.lease_owner {
459 continue;
461 }
462 let owner = first_started.owner.clone();
463 if self.drain_one_owner_bound(&record.id, owner).await {
464 abandoned.push(record.id);
465 }
466 }
467 Ok(ProcessDrainReport { abandoned })
468 }
469
470 async fn drain_one_owner_bound(
475 &self,
476 process_id: &str,
477 owner: crate::LeaseOwnerIdentity,
478 ) -> bool {
479 let lease_ttl_ms = self.lease_timings().ttl_ms();
480 let drain_owner = self.recovery_lease_owner();
481 let lease = match self
482 .config
483 .process_registry
484 .claim_process_lease(process_id, &drain_owner, lease_ttl_ms)
485 .await
486 {
487 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
488 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return false,
490 };
491 if self
492 .config
493 .process_registry
494 .get_process(process_id)
495 .await
496 .is_some_and(|current| current.is_terminal())
497 {
498 self.release_or_log(&lease).await;
499 return false;
500 }
501 let evidence = AbandonEvidence {
502 writer: AbandonWriter::OwnerDrain,
503 owner: Some(owner),
504 epoch_ms: self.now_ms(),
505 };
506 self.complete_and_release(
507 &lease,
508 process_id,
509 ProcessAwaitOutput::Abandoned {
510 evidence: Box::new(evidence),
511 control: None,
512 },
513 )
514 .await;
515 true
516 }
517
518 fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
526 let attempt = uuid::Uuid::new_v4();
527 crate::LeaseOwnerIdentity {
528 owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
529 incarnation_id: attempt.to_string(),
530 liveness: self.config.lease_owner.liveness.clone(),
531 }
532 }
533
534 async fn recover_process(&self, record: ProcessRecord) {
552 let process_id = record.id.clone();
553 if record.disposition == RecoveryDisposition::ExternallyOwned {
557 if record.abandon_request.is_some() {
558 self.reconcile_externally_owned_abandon(&process_id).await;
559 }
560 return;
561 }
562
563 let lease_ttl_ms = self.lease_timings().ttl_ms();
564 let owner = self.recovery_lease_owner();
565 let Some((lease, dead_holder)) = self
570 .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
571 .await
572 else {
573 return;
574 };
575 if self
578 .config
579 .process_registry
580 .get_process(&process_id)
581 .await
582 .is_some_and(|current| current.is_terminal())
583 {
584 self.release_or_log(&lease).await;
585 return;
586 }
587
588 match record.disposition {
589 RecoveryDisposition::Rerunnable => self.run_and_complete(record, lease).await,
591 RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
592 let lapsed_owner = record
596 .first_started
597 .as_ref()
598 .map(|started| started.owner.clone());
599 let evidence = if let Some(dead_holder) = dead_holder {
600 Some(AbandonEvidence {
602 writer: AbandonWriter::Sweep,
603 owner: Some(dead_holder.owner),
604 epoch_ms: self.now_ms(),
605 })
606 } else if record.abandon_request.is_some() {
607 Some(AbandonEvidence {
611 writer: AbandonWriter::ReconciledRequest,
612 owner: lapsed_owner,
613 epoch_ms: self.now_ms(),
614 })
615 } else {
616 None
619 };
620 match evidence {
621 Some(evidence) => {
622 self.complete_and_release(
623 &lease,
624 &process_id,
625 ProcessAwaitOutput::Abandoned {
626 evidence: Box::new(evidence),
627 control: None,
628 },
629 )
630 .await;
631 }
632 None => self.release_or_log(&lease).await,
633 }
634 }
635 RecoveryDisposition::OwnerBound => self.run_and_complete(record, lease).await,
638 RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
640 }
641 }
642
643 fn now_ms(&self) -> u64 {
645 self.config.runtime_host.clock.timestamp_ms()
646 }
647
648 async fn claim_for_recovery(
653 &self,
654 process_id: &str,
655 owner: &crate::LeaseOwnerIdentity,
656 lease_ttl_ms: u64,
657 ) -> Option<(ProcessLease, Option<ProcessLease>)> {
658 match self
659 .config
660 .process_registry
661 .claim_process_lease(process_id, owner, lease_ttl_ms)
662 .await
663 {
664 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => Some((lease, None)),
665 Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
666 if holder.owner.is_definitely_dead_for_claimant(owner) =>
667 {
668 match self
669 .config
670 .process_registry
671 .reclaim_process_lease(process_id, owner, &holder, lease_ttl_ms)
672 .await
673 {
674 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => {
675 Some((lease, Some(holder)))
676 }
677 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
678 }
679 }
680 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
681 }
682 }
683
684 async fn reconcile_externally_owned_abandon(&self, process_id: &str) {
689 let lease_ttl_ms = self.lease_timings().ttl_ms();
690 let owner = self.recovery_lease_owner();
691 let lease = match self
692 .config
693 .process_registry
694 .claim_process_lease(process_id, &owner, lease_ttl_ms)
695 .await
696 {
697 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
698 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
700 };
701 if self
702 .config
703 .process_registry
704 .get_process(process_id)
705 .await
706 .is_some_and(|current| current.is_terminal())
707 {
708 self.release_or_log(&lease).await;
709 return;
710 }
711 let evidence = AbandonEvidence {
712 writer: AbandonWriter::ReconciledRequest,
713 owner: None,
715 epoch_ms: self.now_ms(),
716 };
717 self.complete_and_release(
718 &lease,
719 process_id,
720 ProcessAwaitOutput::Abandoned {
721 evidence: Box::new(evidence),
722 control: None,
723 },
724 )
725 .await;
726 }
727
728 async fn run_and_complete(&self, record: ProcessRecord, lease: ProcessLease) {
731 let process_id = record.id.clone();
732 let registration = registration_from_record(record);
733 let execution_context = ProcessExecutionContext::default();
734 match self
735 .run_process_with_lease_renewal(registration, execution_context, lease.clone())
736 .await
737 {
738 Ok(output) => self.complete_and_release(&lease, &process_id, output).await,
741 Err(RecoverFailure::LeaseLost(err)) => {
747 tracing::warn!(
748 process_id = %process_id,
749 error = %err,
750 "process recovery lost its lease mid-run; deferring to the new owner",
751 );
752 }
753 Err(RecoverFailure::Run(err)) => {
756 let output = ProcessAwaitOutput::Failure {
757 class: crate::ToolFailureClass::Execution,
758 code: "process_recovery_failed".to_string(),
759 message: err.to_string(),
760 raw: None,
761 control: None,
762 };
763 self.complete_and_release(&lease, &process_id, output).await;
764 }
765 }
766 }
767
768 async fn complete_and_release(
771 &self,
772 lease: &ProcessLease,
773 process_id: &str,
774 output: ProcessAwaitOutput,
775 ) {
776 let fenced = match self
781 .config
782 .process_registry
783 .renew_process_lease(lease, self.lease_timings().ttl_ms())
784 .await
785 {
786 Ok(renewed) => renewed,
787 Err(err) => {
788 tracing::warn!(
789 process_id = %process_id,
790 error = %err,
791 "lost process lease before terminal write; deferring to the new owner",
792 );
793 return;
794 }
795 };
796 if let Err(err) = self
797 .config
798 .process_registry
799 .complete_process_with_lease(&fenced, output)
800 .await
801 {
802 tracing::warn!(
803 process_id = %process_id,
804 error = %err,
805 "failed to write recovered process terminal outcome",
806 );
807 }
808 }
809
810 async fn release_or_log(&self, lease: &ProcessLease) {
811 if let Err(err) = self.release_process_lease(lease).await {
812 tracing::warn!(
813 process_id = %lease.process_id,
814 error = %err,
815 "failed to release recovered process lease",
816 );
817 }
818 }
819
820 async fn run_process_with_lease_renewal(
824 &self,
825 registration: ProcessRegistration,
826 execution_context: ProcessExecutionContext,
827 mut lease: ProcessLease,
828 ) -> Result<ProcessAwaitOutput, RecoverFailure> {
829 let process_id = registration.id.clone();
830 let cancellation = CancellationToken::new();
831 let cancel_watcher = {
832 let awaiter = self
833 .config
834 .process_change_hub
835 .clone()
836 .map(|hub| {
837 crate::ProcessAwaiter::new(Arc::clone(&self.config.process_registry), hub)
838 })
839 .unwrap_or_else(|| {
840 crate::ProcessAwaiter::polling(Arc::clone(&self.config.process_registry))
841 });
842 let process_id = process_id.clone();
843 let cancellation = cancellation.clone();
844 tokio::spawn(async move {
845 match awaiter
846 .await_event(&process_id, "process.cancel_requested", 0)
847 .await
848 {
849 Ok(_) => cancellation.cancel(),
850 Err(err) => tracing::warn!(
851 process_id = %process_id,
852 error = %err,
853 "process cancel watcher stopped before observing cancellation",
854 ),
855 }
856 })
857 };
858 let pending = self.run_process(registration, execution_context, cancellation.clone());
859 tokio::pin!(pending);
860 loop {
861 tokio::select! {
862 outcome = &mut pending => {
863 cancel_watcher.abort();
864 return outcome.map_err(RecoverFailure::Run);
865 }
866 _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
867 match self
868 .config
869 .process_registry
870 .renew_process_lease(&lease, self.lease_timings().ttl_ms())
871 .await
872 {
873 Ok(renewed) => lease = renewed,
874 Err(err) => {
875 cancellation.cancel();
876 cancel_watcher.abort();
877 return Err(RecoverFailure::LeaseLost(err));
878 }
879 }
880 }
881 }
882 }
883 }
884
885 fn lease_timings(&self) -> crate::LeaseTimings {
886 self.config.runtime_host.control.lease_timings
887 }
888
889 async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
890 self.config
891 .process_registry
892 .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
893 .await
894 }
895
896 pub async fn request_process_cancel(
897 &self,
898 process_id: &str,
899 reason: Option<String>,
900 ) -> Result<(), PluginError> {
901 self.config
902 .process_registry
903 .append_event(
904 process_id,
905 crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
906 )
907 .await
908 .map(|_| ())
909 }
910
911 async fn runtime_for_registration(
912 &self,
913 registration: &ProcessRegistration,
914 ) -> Result<LashRuntime, PluginError> {
915 match registration.input.as_ref() {
916 ProcessInput::SessionTurn { create_request, .. } => {
917 self.runtime_for_session_turn(registration, create_request.as_ref())
918 .await
919 }
920 ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
921 self.runtime_for_process_env(registration).await
922 }
923 ProcessInput::External { .. } => Err(PluginError::Session(format!(
927 "process `{}` is externally-owned and has no execution runtime",
928 registration.id
929 ))),
930 }
931 }
932
933 async fn runtime_for_session_turn(
934 &self,
935 registration: &ProcessRegistration,
936 create_request: &crate::SessionCreateRequest,
937 ) -> Result<LashRuntime, PluginError> {
938 let mut policy = create_request
939 .policy
940 .clone()
941 .unwrap_or_else(|| self.config.session_policy.clone());
942 if policy.recorded_provider_id().is_empty() {
943 policy.provider_id = self.config.session_policy.provider_id.clone();
944 }
945 self.build_ephemeral_runtime(
946 format!("process-session-turn:{}", registration.id),
947 policy,
948 create_request.plugin_options.clone(),
949 "session turn request",
950 )
951 .await
952 }
953
954 async fn runtime_for_process_env(
955 &self,
956 registration: &ProcessRegistration,
957 ) -> Result<LashRuntime, PluginError> {
958 let Some(env_ref) = registration.env_ref.as_ref() else {
959 return Err(PluginError::Session(format!(
960 "process `{}` is missing a captured execution env",
961 registration.id
962 )));
963 };
964 let env = crate::load_process_execution_env(
965 self.config
966 .runtime_host
967 .durability
968 .process_env_store
969 .as_ref(),
970 env_ref,
971 )
972 .await?;
973 self.build_ephemeral_runtime(
974 format!("process-env:{}", registration.id),
975 env.policy,
976 env.plugin_options,
977 env_ref.as_str(),
978 )
979 .await
980 }
981
982 async fn build_ephemeral_runtime(
983 &self,
984 session_id: String,
985 policy: crate::SessionPolicy,
986 plugin_options: crate::PluginOptions,
987 source_label: &str,
988 ) -> Result<LashRuntime, PluginError> {
989 let store = Arc::new(InMemorySessionStore::default());
990 let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
991 if let Some(hub) = self.config.process_change_hub.clone() {
992 ProcessWorkDriver::from_watched(
993 Arc::clone(&self.config.process_registry),
994 hub,
995 Arc::new(crate::InlineProcessRunHandle::new(self.clone())),
996 )
997 } else {
998 ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
999 }
1000 });
1001 let mut builder = EmbeddedRuntimeBuilder::new()
1002 .with_session_id(session_id.to_string())
1003 .with_plugin_host(self.config.plugin_host.as_ref().clone())
1004 .with_runtime_host(self.config.runtime_host.clone())
1005 .with_policy(policy)
1006 .with_plugin_options(plugin_options)
1007 .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
1008 .with_trigger_store(Arc::clone(&self.config.trigger_store))
1009 .with_process_registry(Arc::clone(&self.config.process_registry))
1010 .with_process_work_driver(process_work_driver)
1011 .with_residency(self.config.residency)
1012 .with_store(store);
1013 if let Some(driver) = self.config.queued_work_driver.clone() {
1014 builder = builder.with_queued_work_driver(driver);
1015 }
1016 builder.build().await.map_err(|err| {
1017 PluginError::Session(format!(
1018 "failed to build process worker runtime for {source_label}: {err}"
1019 ))
1020 })
1021 }
1022
1023 fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
1032 if self
1033 .config
1034 .runtime_host
1035 .control
1036 .effect_host
1037 .durability_tier()
1038 != crate::DurabilityTier::Durable
1039 {
1040 return Ok(());
1041 }
1042 let require = |facet: crate::DurableStoreFacet| {
1043 PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
1044 };
1045 if self
1046 .config
1047 .runtime_host
1048 .durability
1049 .attachment_store
1050 .persistence()
1051 .durability_tier()
1052 != crate::DurabilityTier::Durable
1053 {
1054 return Err(require(crate::DurableStoreFacet::AttachmentStore));
1055 }
1056 if self
1057 .config
1058 .runtime_host
1059 .durability
1060 .process_env_store
1061 .durability_tier()
1062 != crate::DurabilityTier::Durable
1063 {
1064 return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
1065 }
1066 if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
1067 return Err(require(crate::DurableStoreFacet::SessionStore));
1068 }
1069 if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
1070 return Err(require(crate::DurableStoreFacet::ProcessRegistry));
1071 }
1072 if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
1073 return Err(require(crate::DurableStoreFacet::TriggerStore));
1074 }
1075 Ok(())
1076 }
1077
1078 fn ensure_stable_process_id(
1086 &self,
1087 registration: &ProcessRegistration,
1088 ) -> Result<(), PluginError> {
1089 if registration.id.trim().is_empty() {
1090 return Err(PluginError::Session(
1091 crate::RuntimeError::missing_process_execution_id().to_string(),
1092 ));
1093 }
1094 Ok(())
1095 }
1096}
1097
1098fn registration_from_record(record: ProcessRecord) -> ProcessRegistration {
1101 ProcessRegistration {
1102 id: record.id,
1103 input: record.input,
1104 disposition: record.disposition,
1105 identity: record.identity,
1106 event_types: record.event_types,
1107 provenance: record.provenance,
1108 env_ref: record.env_ref,
1109 wake_target: record.wake_target,
1110 }
1111}
1112
1113#[cfg(test)]
1114mod boundary_tests;
1115#[cfg(test)]
1116mod recovery_tests;