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 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 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 continue;
495 };
496 if first_started.owner != self.config.lease_owner {
497 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 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 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 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 async fn recover_process(&self, record: ProcessRecord) {
590 let process_id = record.id.clone();
591 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 let Some((lease, dead_holder)) = self
608 .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
609 .await
610 else {
611 return;
612 };
613 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 RecoveryDisposition::Rerunnable => self.run_and_complete(record, lease).await,
629 RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
630 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 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 Some(AbandonEvidence {
649 writer: AbandonWriter::ReconciledRequest,
650 owner: lapsed_owner,
651 epoch_ms: self.now_ms(),
652 })
653 } else {
654 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 RecoveryDisposition::OwnerBound => self.run_and_complete(record, lease).await,
676 RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
678 }
679 }
680
681 fn now_ms(&self) -> u64 {
683 self.config.runtime_host.clock.timestamp_ms()
684 }
685
686 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 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 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 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 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 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 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 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 async fn complete_and_release(
831 &self,
832 lease: &ProcessLease,
833 process_id: &str,
834 output: ProcessAwaitOutput,
835 ) {
836 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 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 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 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 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
1177fn 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;