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 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 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(®istration)?;
261 self.ensure_durable_store_facets()?;
262 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 self.config
277 .process_registry
278 .record_first_started(
279 ®istration.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(®istration).await?;
287 let originator_scope = if let crate::ProcessOriginator::Session { scope } =
288 ®istration.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 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 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 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 continue;
487 };
488 if first_started.owner != self.config.lease_owner {
489 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 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 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 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 async fn recover_process(&self, record: ProcessRecord) {
582 let process_id = record.id.clone();
583 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 let Some((lease, dead_holder)) = self
600 .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
601 .await
602 else {
603 return;
604 };
605 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 RecoveryDisposition::Rerunnable => self.run_and_complete(record, lease).await,
621 RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
622 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 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 Some(AbandonEvidence {
641 writer: AbandonWriter::ReconciledRequest,
642 owner: lapsed_owner,
643 epoch_ms: self.now_ms(),
644 })
645 } else {
646 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 RecoveryDisposition::OwnerBound => self.run_and_complete(record, lease).await,
668 RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
670 }
671 }
672
673 fn now_ms(&self) -> u64 {
675 self.config.runtime_host.clock.timestamp_ms()
676 }
677
678 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 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 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 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 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 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 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 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 async fn complete_and_release(
823 &self,
824 lease: &ProcessLease,
825 process_id: &str,
826 output: ProcessAwaitOutput,
827 ) {
828 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 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 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 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 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
1169fn 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;