1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::future::Future;
3use std::sync::Arc;
4
5use tokio::sync::{OwnedSemaphorePermit, Semaphore};
6use tokio_util::sync::CancellationToken;
7
8use super::effect::ProcessRunner;
9use super::session_manager::RuntimeSessionServices;
10use super::{EmbeddedRuntimeBuilder, ProcessWorkDriver, QueuedWorkDriver, RuntimeHostConfig};
11use crate::InMemorySessionStore;
12use crate::{
13 AbandonEvidence, AbandonWriter, LashRuntime, PluginError, PluginFactory, PluginHost,
14 PluginStack, ProcessAwaitOutput, ProcessExecutionContext, ProcessInput, ProcessLease,
15 ProcessLeaseCompletion, ProcessRecord, ProcessRegistration, ProcessRegistry,
16 RecoveryDisposition, SessionStoreFactory,
17};
18
19pub const DEFAULT_PROCESS_EXECUTION_CONCURRENCY: usize = 64;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25struct ProcessExecutionConcurrency(usize);
26
27impl ProcessExecutionConcurrency {
28 const DEFAULT: Self = Self(DEFAULT_PROCESS_EXECUTION_CONCURRENCY);
29
30 fn new(concurrency: usize) -> Result<Self, ProcessExecutionConcurrencyError> {
31 if !(1..=Semaphore::MAX_PERMITS).contains(&concurrency) {
32 return Err(ProcessExecutionConcurrencyError { concurrency });
33 }
34 Ok(Self(concurrency))
35 }
36
37 fn get(self) -> usize {
38 self.0
39 }
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
44#[error(
45 "process execution concurrency must be between 1 and {max} (inclusive), got {concurrency}",
46 max = Semaphore::MAX_PERMITS
47)]
48pub struct ProcessExecutionConcurrencyError {
49 concurrency: usize,
50}
51
52#[derive(Clone)]
58pub struct DurableProcessWorkerConfig {
59 pub plugin_host: Arc<PluginHost>,
60 pub runtime_host: RuntimeHostConfig,
61 pub session_policy: crate::SessionPolicy,
62 pub session_store_factory: Arc<dyn SessionStoreFactory>,
63 pub process_registry: Arc<dyn ProcessRegistry>,
64 pub process_change_hub: Option<crate::ProcessChangeHub>,
65 pub trigger_store: Arc<dyn crate::TriggerStore>,
66 pub process_work_driver: Option<ProcessWorkDriver>,
67 pub queued_work_driver: Option<QueuedWorkDriver>,
68 #[doc(hidden)]
69 pub turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
70 pub residency: crate::Residency,
76 process_execution_concurrency: ProcessExecutionConcurrency,
81 pub lease_owner: crate::LeaseOwnerIdentity,
94}
95
96impl DurableProcessWorkerConfig {
97 pub fn validate_process_execution_concurrency(
98 concurrency: usize,
99 ) -> Result<(), ProcessExecutionConcurrencyError> {
100 ProcessExecutionConcurrency::new(concurrency).map(drop)
101 }
102
103 pub fn new(
104 plugin_host: Arc<PluginHost>,
105 runtime_host: RuntimeHostConfig,
106 session_store_factory: Arc<dyn SessionStoreFactory>,
107 process_registry: Arc<dyn ProcessRegistry>,
108 ) -> Self {
109 let clock = Arc::clone(&runtime_host.clock);
110 Self {
111 plugin_host,
112 runtime_host,
113 session_policy: crate::SessionPolicy::default(),
114 session_store_factory,
115 process_registry,
116 process_change_hub: None,
117 trigger_store: Arc::new(crate::InMemoryTriggerStore::with_clock(clock)),
118 process_work_driver: None,
119 queued_work_driver: None,
120 turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot::default(),
121 residency: crate::Residency::default(),
122 process_execution_concurrency: ProcessExecutionConcurrency::DEFAULT,
123 lease_owner: crate::LeaseOwnerIdentity::opaque(
124 format!("durable-process-worker:{}", uuid::Uuid::new_v4()),
125 uuid::Uuid::new_v4().to_string(),
126 ),
127 }
128 }
129
130 pub fn with_trigger_store(mut self, store: Arc<dyn crate::TriggerStore>) -> Self {
131 self.trigger_store = store;
132 self
133 }
134
135 pub fn with_session_policy(mut self, policy: crate::SessionPolicy) -> Self {
136 self.session_policy = policy;
137 self
138 }
139
140 pub fn with_residency(mut self, residency: crate::Residency) -> Self {
141 self.residency = residency;
142 self
143 }
144
145 pub fn with_process_execution_concurrency(
150 mut self,
151 concurrency: usize,
152 ) -> Result<Self, ProcessExecutionConcurrencyError> {
153 self.process_execution_concurrency = ProcessExecutionConcurrency::new(concurrency)?;
154 Ok(self)
155 }
156
157 pub fn process_execution_concurrency(&self) -> usize {
158 self.process_execution_concurrency.get()
159 }
160
161 pub fn with_process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
162 self.process_work_driver = Some(driver);
163 self
164 }
165
166 pub fn with_change_hub(mut self, hub: crate::ProcessChangeHub) -> Self {
167 self.process_change_hub = Some(hub);
168 self
169 }
170
171 pub fn with_lease_owner(mut self, lease_owner: crate::LeaseOwnerIdentity) -> Self {
174 self.lease_owner = lease_owner;
175 self
176 }
177
178 pub fn with_queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
179 self.queued_work_driver = Some(driver);
180 self
181 }
182
183 #[doc(hidden)]
184 pub fn with_turn_phase_probe_slot(
185 mut self,
186 slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
187 ) -> Self {
188 self.turn_phase_probe_slot = slot;
189 self
190 }
191
192 pub fn from_plugin_factories(
193 plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
194 runtime_host: RuntimeHostConfig,
195 session_store_factory: Arc<dyn SessionStoreFactory>,
196 process_registry: Arc<dyn ProcessRegistry>,
197 ) -> Self {
198 Self::new(
199 Arc::new(PluginHost::new(plugin_factories.into_iter().collect())),
200 runtime_host,
201 session_store_factory,
202 process_registry,
203 )
204 }
205
206 pub fn from_plugin_stack(
207 plugin_stack: PluginStack,
208 runtime_host: RuntimeHostConfig,
209 session_store_factory: Arc<dyn SessionStoreFactory>,
210 process_registry: Arc<dyn ProcessRegistry>,
211 ) -> Self {
212 Self::from_plugin_factories(
213 plugin_stack.into_factories(),
214 runtime_host,
215 session_store_factory,
216 process_registry,
217 )
218 }
219}
220
221#[derive(Clone)]
223pub struct DurableProcessWorker {
224 config: Arc<DurableProcessWorkerConfig>,
225 execution_scheduler: Arc<ProcessExecutionScheduler>,
226}
227
228#[derive(Default)]
229struct ProcessExecutionSchedulerState {
230 pending: VecDeque<ProcessRecord>,
231 scheduled: BTreeSet<String>,
232 rerun: BTreeMap<String, ProcessRecord>,
233 active: usize,
234 dispatcher_running: bool,
235}
236
237struct ProcessExecutionScheduler {
238 permits: Arc<Semaphore>,
239 state: tokio::sync::Mutex<ProcessExecutionSchedulerState>,
240 changed: Arc<tokio::sync::Notify>,
241}
242
243impl ProcessExecutionScheduler {
244 fn new(concurrency: ProcessExecutionConcurrency) -> Self {
245 Self {
246 permits: Arc::new(Semaphore::new(concurrency.get())),
247 state: tokio::sync::Mutex::new(ProcessExecutionSchedulerState::default()),
248 changed: Arc::new(tokio::sync::Notify::new()),
249 }
250 }
251}
252
253struct ProcessExecutionDispatcherGuard {
257 scheduler: Arc<ProcessExecutionScheduler>,
258 armed: bool,
259}
260
261impl ProcessExecutionDispatcherGuard {
262 fn new(scheduler: Arc<ProcessExecutionScheduler>) -> Self {
263 Self {
264 scheduler,
265 armed: true,
266 }
267 }
268
269 fn disarm(&mut self) {
270 self.armed = false;
271 }
272}
273
274impl Drop for ProcessExecutionDispatcherGuard {
275 fn drop(&mut self) {
276 if !self.armed {
277 return;
278 }
279 if let Ok(mut state) = self.scheduler.state.try_lock() {
280 state.dispatcher_running = false;
281 self.scheduler.changed.notify_one();
282 return;
283 }
284 let scheduler = Arc::clone(&self.scheduler);
285 crate::task::spawn(async move {
286 scheduler.state.lock().await.dispatcher_running = false;
287 scheduler.changed.notify_one();
288 });
289 }
290}
291
292struct ProcessExecutionTaskCompletion {
293 process_id: String,
294 completed: tokio::sync::mpsc::UnboundedSender<String>,
295}
296
297impl Drop for ProcessExecutionTaskCompletion {
298 fn drop(&mut self) {
299 let _ = self.completed.send(self.process_id.clone());
300 }
301}
302
303struct ProcessExecutionPermit {
313 semaphore: Arc<Semaphore>,
314 held: std::sync::Mutex<Option<OwnedSemaphorePermit>>,
315 reacquire: tokio::sync::Mutex<()>,
316 dispatcher_changed: Arc<tokio::sync::Notify>,
317}
318
319impl ProcessExecutionPermit {
320 fn new(
321 semaphore: Arc<Semaphore>,
322 permit: OwnedSemaphorePermit,
323 dispatcher_changed: Arc<tokio::sync::Notify>,
324 ) -> Self {
325 Self {
326 semaphore,
327 held: std::sync::Mutex::new(Some(permit)),
328 reacquire: tokio::sync::Mutex::new(()),
329 dispatcher_changed,
330 }
331 }
332
333 async fn ensure_acquired(&self) {
334 if self
335 .held
336 .lock()
337 .expect("process execution permit lock")
338 .is_some()
339 {
340 return;
341 }
342 let _reacquire = self.reacquire.lock().await;
343 if self
344 .held
345 .lock()
346 .expect("process execution permit lock")
347 .is_some()
348 {
349 return;
350 }
351 let permit = Arc::clone(&self.semaphore)
352 .acquire_owned()
353 .await
354 .expect("process execution semaphore remains open");
355 *self.held.lock().expect("process execution permit lock") = Some(permit);
356 }
357
358 async fn release_while<F: Future>(&self, future: F) -> F::Output {
359 let released = self
360 .held
361 .lock()
362 .expect("process execution permit lock")
363 .take();
364 let Some(released) = released else {
365 return future.await;
366 };
367 drop(released);
368 self.dispatcher_changed.notify_one();
369 let output = future.await;
370 self.ensure_acquired().await;
371 output
372 }
373}
374
375tokio::task_local! {
376 static PROCESS_EXECUTION_PERMIT: Arc<ProcessExecutionPermit>;
377}
378
379pub(crate) async fn release_process_execution_permit_while<F: Future>(future: F) -> F::Output {
380 let permit = PROCESS_EXECUTION_PERMIT.try_with(Arc::clone).ok();
383 match permit {
384 Some(permit) => permit.release_while(future).await,
385 None => future.await,
386 }
387}
388
389pub(crate) async fn ensure_process_execution_permit() {
390 if let Ok(permit) = PROCESS_EXECUTION_PERMIT.try_with(Arc::clone) {
391 permit.ensure_acquired().await;
392 }
393}
394
395pub(crate) fn inherit_process_execution_permit<F: Future>(
396 future: F,
397) -> impl Future<Output = F::Output> {
398 let permit = PROCESS_EXECUTION_PERMIT.try_with(Arc::clone).ok();
399 async move {
400 match permit {
401 Some(permit) => PROCESS_EXECUTION_PERMIT.scope(permit, future).await,
402 None => future.await,
403 }
404 }
405}
406
407#[derive(Clone, Debug, Default, PartialEq, Eq)]
409pub struct ProcessDrainReport {
410 pub abandoned: Vec<String>,
413}
414
415enum RecoverFailure {
417 LeaseLost(PluginError),
421 Run(PluginError),
424}
425
426impl DurableProcessWorker {
427 pub fn new(config: DurableProcessWorkerConfig) -> Self {
428 let execution_scheduler = Arc::new(ProcessExecutionScheduler::new(
429 config.process_execution_concurrency,
430 ));
431 Self {
432 config: Arc::new(config),
433 execution_scheduler,
434 }
435 }
436
437 pub fn from_shared_config(config: Arc<DurableProcessWorkerConfig>) -> Self {
438 let execution_scheduler = Arc::new(ProcessExecutionScheduler::new(
439 config.process_execution_concurrency,
440 ));
441 Self {
442 config,
443 execution_scheduler,
444 }
445 }
446
447 pub fn config(&self) -> &DurableProcessWorkerConfig {
448 &self.config
449 }
450
451 pub async fn run_process(
452 &self,
453 registration: ProcessRegistration,
454 execution_context: ProcessExecutionContext,
455 cancellation: CancellationToken,
456 ) -> Result<ProcessAwaitOutput, PluginError> {
457 let scoped_effect_controller = self
458 .config
459 .runtime_host
460 .control
461 .effect_host
462 .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
463 .map_err(|err| PluginError::Session(err.to_string()))?
464 .ok_or_else(|| {
465 PluginError::Session(
466 "process worker effect host must provide a static process scope".to_string(),
467 )
468 })?;
469 Box::pin(self.run_process_with_scoped_effect_controller(
470 registration,
471 execution_context,
472 scoped_effect_controller,
473 cancellation,
474 ))
475 .await
476 }
477
478 pub async fn run_process_with_scoped_effect_controller(
479 &self,
480 registration: ProcessRegistration,
481 execution_context: ProcessExecutionContext,
482 scoped_effect_controller: crate::ScopedEffectController<'_>,
483 cancellation: CancellationToken,
484 ) -> Result<ProcessAwaitOutput, PluginError> {
485 let mut handover = None;
486 loop {
487 match Box::pin(self.run_process_segment_with_scoped_effect_controller(
488 registration.clone(),
489 execution_context.clone(),
490 scoped_effect_controller.clone(),
491 cancellation.clone(),
492 handover,
493 ))
494 .await?
495 {
496 crate::ProcessRunOutcome::Terminal(output) => return Ok(*output),
497 crate::ProcessRunOutcome::SegmentBoundary(next) => handover = Some(next),
498 }
499 }
500 }
501
502 pub async fn run_process_segment_with_scoped_effect_controller(
506 &self,
507 registration: ProcessRegistration,
508 execution_context: ProcessExecutionContext,
509 scoped_effect_controller: crate::ScopedEffectController<'_>,
510 cancellation: CancellationToken,
511 handover: Option<crate::SegmentHandover>,
512 ) -> Result<crate::ProcessRunOutcome, PluginError> {
513 self.ensure_stable_process_id(®istration)?;
514 self.ensure_durable_store_facets()?;
515 if registration.disposition == RecoveryDisposition::ExternallyOwned {
519 return Err(PluginError::Session(format!(
520 "process `{}` is externally-owned and must not be executed by lash",
521 registration.id
522 )));
523 }
524 self.config
530 .process_registry
531 .record_first_started(
532 ®istration.id,
533 crate::ProcessStarted {
534 owner: self.config.lease_owner.clone(),
535 started_at_ms: self.now_ms(),
536 },
537 )
538 .await?;
539 let mut runtime = Box::pin(self.runtime_for_registration(®istration)).await?;
540 let _attachment_owner_binding = matches!(
541 registration.input.as_ref(),
542 ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. }
543 )
544 .then(|| {
545 runtime
546 .host
547 .core
548 .durability
549 .attachment_store
550 .bind_process_scoped(registration.id.clone())
551 });
552 let originator_scope = if let crate::ProcessOriginator::Session { scope } =
553 ®istration.provenance.originator
554 {
555 Some(scope)
556 } else {
557 None
558 };
559 let probe_scope = registration.wake_target.as_ref().or(originator_scope);
560 if let Some(probe) =
561 probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
562 {
563 runtime.set_turn_phase_probe(probe);
564 }
565 let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
566 PluginError::Session(format!(
567 "failed to build runtime env for process `{}`: {err}",
568 registration.id
569 ))
570 })?;
571 Ok(manager
572 .run_process(
573 registration,
574 execution_context,
575 Arc::clone(&self.config.process_registry),
576 scoped_effect_controller,
577 cancellation,
578 handover,
579 )
580 .await)
581 }
582
583 pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
610 self.reconcile_trigger_deliveries().await?;
611 let records = self.config.process_registry.list_non_terminal().await?;
612 let should_start_dispatcher = {
613 let mut state = self.execution_scheduler.state.lock().await;
614 for record in records {
615 if state.scheduled.insert(record.id.clone()) {
616 state.pending.push_back(record);
617 } else {
618 state.rerun.insert(record.id.clone(), record);
623 }
624 }
625 if state.dispatcher_running {
626 false
627 } else {
628 state.dispatcher_running = true;
629 true
630 }
631 };
632 self.execution_scheduler.changed.notify_one();
633 if should_start_dispatcher {
634 let worker = self.clone();
635 crate::task::spawn(async move { worker.run_process_execution_dispatcher().await });
636 }
637 Ok(())
638 }
639
640 async fn run_process_execution_dispatcher(&self) {
641 let mut dispatcher_guard =
642 ProcessExecutionDispatcherGuard::new(Arc::clone(&self.execution_scheduler));
643 let (completed_tx, mut completed_rx) = tokio::sync::mpsc::unbounded_channel();
644 loop {
645 while let Some((record, permit)) = self.next_process_execution().await {
646 let worker = self.clone();
647 let completion = ProcessExecutionTaskCompletion {
648 process_id: record.id.clone(),
649 completed: completed_tx.clone(),
650 };
651 let execution_permit = Arc::new(ProcessExecutionPermit::new(
652 Arc::clone(&self.execution_scheduler.permits),
653 permit,
654 Arc::clone(&self.execution_scheduler.changed),
655 ));
656 crate::task::spawn(async move {
657 let _completion = completion;
658 Box::pin(
661 PROCESS_EXECUTION_PERMIT
662 .scope(execution_permit, worker.recover_process(record)),
663 )
664 .await;
665 });
666 }
667
668 {
669 let mut state = self.execution_scheduler.state.lock().await;
670 if state.pending.is_empty() && state.active == 0 {
671 state.dispatcher_running = false;
672 dispatcher_guard.disarm();
673 return;
674 }
675 }
676
677 tokio::select! {
678 Some(process_id) = completed_rx.recv() => {
679 let mut state = self.execution_scheduler.state.lock().await;
680 state.active = state
681 .active
682 .checked_sub(1)
683 .expect("a completed process execution was active");
684 if let Some(record) = state.rerun.remove(&process_id) {
685 state.pending.push_back(record);
686 } else {
687 state.scheduled.remove(&process_id);
688 }
689 }
690 _ = self.execution_scheduler.changed.notified() => {}
691 }
692 }
693 }
694
695 async fn next_process_execution(&self) -> Option<(ProcessRecord, OwnedSemaphorePermit)> {
696 let mut state = self.execution_scheduler.state.lock().await;
697 let permit = Arc::clone(&self.execution_scheduler.permits)
698 .try_acquire_owned()
699 .ok()?;
700 let record = state.pending.pop_front()?;
701 state.active += 1;
702 Some((record, permit))
703 }
704
705 async fn reconcile_trigger_deliveries(&self) -> Result<(), PluginError> {
706 let candidates = self.config.trigger_store.list_deliveries().await?;
707 if candidates.is_empty() {
708 return Ok(());
709 }
710 let candidate_process_ids = candidates
711 .iter()
712 .map(|delivery| delivery.process_id.clone())
713 .collect::<Vec<_>>();
714 let missing_process_ids = self
715 .config
716 .process_registry
717 .filter_unregistered_process_ids(&candidate_process_ids)
718 .await?
719 .into_iter()
720 .collect::<BTreeSet<_>>();
721 let router = crate::TriggerRouter::new(
722 Arc::clone(&self.config.trigger_store),
723 Some(Arc::clone(&self.config.process_registry)),
724 self.config.process_work_driver.clone(),
725 );
726 let mut started_any = false;
727 for delivery in candidates {
728 if missing_process_ids.contains(&delivery.process_id) {
729 let Some(scoped_effect_controller) = self
730 .config
731 .runtime_host
732 .control
733 .effect_host
734 .scoped_static(crate::ExecutionScope::runtime_operation(format!(
735 "trigger-delivery-reconcile:{}",
736 delivery.process_id
737 )))
738 .map_err(|err| PluginError::Session(err.to_string()))?
739 else {
740 return Err(PluginError::Session(
741 "process worker effect host must provide a static trigger delivery reconcile scope"
742 .to_string(),
743 ));
744 };
745 match router
746 .start_delivery(
747 &delivery,
748 Arc::clone(&self.config.process_registry),
749 scoped_effect_controller.controller(),
750 )
751 .await
752 {
753 Ok(()) => started_any = true,
754 Err(err) => tracing::warn!(
755 process_id = %delivery.process_id,
756 occurrence_id = %delivery.occurrence.occurrence_id,
757 subscription_id = %delivery.subscription.subscription_id,
758 error = %err,
759 "failed to reconcile trigger delivery",
760 ),
761 }
762 }
763 }
764 if started_any && let Some(driver) = self.config.process_work_driver.as_ref() {
765 driver
766 .claim_and_run_pending("trigger_delivery_reconcile")
767 .await?;
768 }
769 Ok(())
770 }
771
772 pub async fn drain_owner_bound_work(&self) -> Result<ProcessDrainReport, PluginError> {
804 let mut abandoned = Vec::new();
805 for record in self.config.process_registry.list_non_terminal().await? {
806 if record.disposition != RecoveryDisposition::OwnerBound {
807 continue;
808 }
809 let Some(first_started) = record.first_started.as_ref() else {
810 continue;
814 };
815 if first_started.owner != self.config.lease_owner {
816 continue;
818 }
819 let owner = first_started.owner.clone();
820 if self.drain_one_owner_bound(&record.id, owner).await {
821 abandoned.push(record.id);
822 }
823 }
824 Ok(ProcessDrainReport { abandoned })
825 }
826
827 async fn drain_one_owner_bound(
832 &self,
833 process_id: &str,
834 owner: crate::LeaseOwnerIdentity,
835 ) -> bool {
836 let lease_ttl_ms = self.lease_timings().ttl_ms();
837 let drain_owner = self.recovery_lease_owner();
838 let lease = match self
839 .config
840 .process_registry
841 .claim_process_lease(process_id, &drain_owner, lease_ttl_ms)
842 .await
843 {
844 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
845 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return false,
847 };
848 if self
849 .config
850 .process_registry
851 .get_process(process_id)
852 .await
853 .is_some_and(|current| current.is_terminal())
854 {
855 self.release_or_log(&lease).await;
856 return false;
857 }
858 let evidence = AbandonEvidence {
859 writer: AbandonWriter::OwnerDrain,
860 owner: Some(owner),
861 epoch_ms: self.now_ms(),
862 };
863 self.complete_and_release(
864 &lease,
865 process_id,
866 ProcessAwaitOutput::Abandoned {
867 evidence: Box::new(evidence),
868 control: None,
869 },
870 )
871 .await;
872 true
873 }
874
875 fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
883 let attempt = uuid::Uuid::new_v4();
884 crate::LeaseOwnerIdentity {
885 owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
886 incarnation_id: attempt.to_string(),
887 liveness: self.config.lease_owner.liveness.clone(),
888 }
889 }
890
891 async fn recover_process(&self, record: ProcessRecord) {
909 let process_id = record.id.clone();
910 if record.disposition == RecoveryDisposition::ExternallyOwned {
914 if record.abandon_request.is_some() {
915 self.reconcile_externally_owned_abandon(&process_id).await;
916 }
917 return;
918 }
919
920 let lease_ttl_ms = self.lease_timings().ttl_ms();
921 let owner = self.recovery_lease_owner();
922 let Some((lease, dead_holder)) = self
927 .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
928 .await
929 else {
930 return;
931 };
932 if self
935 .config
936 .process_registry
937 .get_process(&process_id)
938 .await
939 .is_some_and(|current| current.is_terminal())
940 {
941 self.release_or_log(&lease).await;
942 return;
943 }
944
945 match record.disposition {
946 RecoveryDisposition::Rerunnable => Box::pin(self.run_and_complete(record, lease)).await,
948 RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
949 let lapsed_owner = record
953 .first_started
954 .as_ref()
955 .map(|started| started.owner.clone());
956 let evidence = if let Some(dead_holder) = dead_holder {
957 Some(AbandonEvidence {
959 writer: AbandonWriter::Sweep,
960 owner: Some(dead_holder.owner),
961 epoch_ms: self.now_ms(),
962 })
963 } else if record.abandon_request.is_some() {
964 Some(AbandonEvidence {
968 writer: AbandonWriter::ReconciledRequest,
969 owner: lapsed_owner,
970 epoch_ms: self.now_ms(),
971 })
972 } else {
973 None
976 };
977 match evidence {
978 Some(evidence) => {
979 self.complete_and_release(
980 &lease,
981 &process_id,
982 ProcessAwaitOutput::Abandoned {
983 evidence: Box::new(evidence),
984 control: None,
985 },
986 )
987 .await;
988 }
989 None => self.release_or_log(&lease).await,
990 }
991 }
992 RecoveryDisposition::OwnerBound => Box::pin(self.run_and_complete(record, lease)).await,
995 RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
997 }
998 }
999
1000 fn now_ms(&self) -> u64 {
1002 self.config.runtime_host.clock.timestamp_ms()
1003 }
1004
1005 async fn claim_for_recovery(
1010 &self,
1011 process_id: &str,
1012 owner: &crate::LeaseOwnerIdentity,
1013 lease_ttl_ms: u64,
1014 ) -> Option<(ProcessLease, Option<ProcessLease>)> {
1015 match self
1016 .config
1017 .process_registry
1018 .claim_process_lease(process_id, owner, lease_ttl_ms)
1019 .await
1020 {
1021 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => Some((lease, None)),
1022 Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
1023 if holder.owner.is_definitely_dead_for_claimant(owner) =>
1024 {
1025 match self
1026 .config
1027 .process_registry
1028 .reclaim_process_lease(process_id, owner, &holder, lease_ttl_ms)
1029 .await
1030 {
1031 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => {
1032 Some((lease, Some(holder)))
1033 }
1034 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
1035 }
1036 }
1037 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
1038 }
1039 }
1040
1041 async fn reconcile_externally_owned_abandon(&self, process_id: &str) {
1046 let lease_ttl_ms = self.lease_timings().ttl_ms();
1047 let owner = self.recovery_lease_owner();
1048 let lease = match self
1049 .config
1050 .process_registry
1051 .claim_process_lease(process_id, &owner, lease_ttl_ms)
1052 .await
1053 {
1054 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
1055 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
1057 };
1058 if self
1059 .config
1060 .process_registry
1061 .get_process(process_id)
1062 .await
1063 .is_some_and(|current| current.is_terminal())
1064 {
1065 self.release_or_log(&lease).await;
1066 return;
1067 }
1068 let evidence = AbandonEvidence {
1069 writer: AbandonWriter::ReconciledRequest,
1070 owner: None,
1072 epoch_ms: self.now_ms(),
1073 };
1074 self.complete_and_release(
1075 &lease,
1076 process_id,
1077 ProcessAwaitOutput::Abandoned {
1078 evidence: Box::new(evidence),
1079 control: None,
1080 },
1081 )
1082 .await;
1083 }
1084
1085 async fn run_and_complete(&self, record: ProcessRecord, lease: ProcessLease) {
1088 let process_id = record.id.clone();
1089 let registration = registration_from_record(record);
1090 let execution_context = ProcessExecutionContext::default();
1091 let mut handover = None;
1092 loop {
1093 match Box::pin(self.run_process_with_lease_renewal(
1094 registration.clone(),
1095 execution_context.clone(),
1096 lease.clone(),
1097 handover,
1098 ))
1099 .await
1100 {
1101 Ok(crate::ProcessRunOutcome::Terminal(output)) => {
1104 self.complete_and_release(&lease, &process_id, *output)
1105 .await;
1106 return;
1107 }
1108 Ok(crate::ProcessRunOutcome::SegmentBoundary(next)) => {
1109 tracing::debug!(
1110 process_id = %process_id,
1111 reason = ?next.reason,
1112 "process crossed an in-memory segment boundary",
1113 );
1114 handover = Some(next);
1115 }
1116 Err(RecoverFailure::LeaseLost(err)) => {
1122 tracing::warn!(
1123 process_id = %process_id,
1124 error = %err,
1125 "process recovery lost its lease mid-run; deferring to the new owner",
1126 );
1127 return;
1128 }
1129 Err(RecoverFailure::Run(err)) => {
1132 let output = ProcessAwaitOutput::Failure {
1133 class: crate::ToolFailureClass::Execution,
1134 code: "process_recovery_failed".to_string(),
1135 message: err.to_string(),
1136 raw: None,
1137 control: None,
1138 };
1139 self.complete_and_release(&lease, &process_id, output).await;
1140 return;
1141 }
1142 }
1143 }
1144 }
1145
1146 async fn complete_and_release(
1149 &self,
1150 lease: &ProcessLease,
1151 process_id: &str,
1152 output: ProcessAwaitOutput,
1153 ) {
1154 let fenced = match self
1159 .config
1160 .process_registry
1161 .renew_process_lease(lease, self.lease_timings().ttl_ms())
1162 .await
1163 {
1164 Ok(renewed) => renewed,
1165 Err(err) => {
1166 tracing::warn!(
1167 process_id = %process_id,
1168 error = %err,
1169 "lost process lease before terminal write; deferring to the new owner",
1170 );
1171 return;
1172 }
1173 };
1174 if let Err(err) = self
1175 .config
1176 .process_registry
1177 .complete_process_with_lease(&fenced, output)
1178 .await
1179 {
1180 tracing::warn!(
1181 process_id = %process_id,
1182 error = %err,
1183 "failed to write recovered process terminal outcome",
1184 );
1185 }
1186 }
1187
1188 async fn release_or_log(&self, lease: &ProcessLease) {
1189 if let Err(err) = self.release_process_lease(lease).await {
1190 tracing::warn!(
1191 process_id = %lease.process_id,
1192 error = %err,
1193 "failed to release recovered process lease",
1194 );
1195 }
1196 }
1197
1198 async fn run_process_with_lease_renewal(
1202 &self,
1203 registration: ProcessRegistration,
1204 execution_context: ProcessExecutionContext,
1205 mut lease: ProcessLease,
1206 handover: Option<crate::SegmentHandover>,
1207 ) -> Result<crate::ProcessRunOutcome, RecoverFailure> {
1208 let process_id = registration.id.clone();
1209 let cancellation = CancellationToken::new();
1210 let cancel_watcher = {
1211 let awaiter = self
1212 .config
1213 .process_change_hub
1214 .clone()
1215 .map(|hub| {
1216 crate::ProcessAwaiter::new(Arc::clone(&self.config.process_registry), hub)
1217 })
1218 .unwrap_or_else(|| {
1219 crate::ProcessAwaiter::polling(Arc::clone(&self.config.process_registry))
1220 });
1221 let process_id = process_id.clone();
1222 let cancellation = cancellation.clone();
1223 crate::task::spawn(async move {
1224 match awaiter
1225 .await_event(&process_id, "process.cancel_requested", 0)
1226 .await
1227 {
1228 Ok(_) => cancellation.cancel(),
1229 Err(err) => tracing::warn!(
1230 process_id = %process_id,
1231 error = %err,
1232 "process cancel watcher stopped before observing cancellation",
1233 ),
1234 }
1235 })
1236 };
1237 let scoped_effect_controller = self
1238 .config
1239 .runtime_host
1240 .control
1241 .effect_host
1242 .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
1243 .map_err(|err| RecoverFailure::Run(PluginError::Session(err.to_string())))?
1244 .ok_or_else(|| {
1245 RecoverFailure::Run(PluginError::Session(
1246 "process worker effect host must provide a static process scope".to_string(),
1247 ))
1248 })?;
1249 let pending = self.run_process_segment_with_scoped_effect_controller(
1250 registration,
1251 execution_context,
1252 scoped_effect_controller,
1253 cancellation.clone(),
1254 handover,
1255 );
1256 tokio::pin!(pending);
1257 loop {
1258 tokio::select! {
1259 outcome = &mut pending => {
1260 cancel_watcher.abort();
1261 return outcome.map_err(RecoverFailure::Run);
1262 }
1263 _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
1264 match self
1265 .config
1266 .process_registry
1267 .renew_process_lease(&lease, self.lease_timings().ttl_ms())
1268 .await
1269 {
1270 Ok(renewed) => lease = renewed,
1271 Err(err) => {
1272 cancellation.cancel();
1273 cancel_watcher.abort();
1274 return Err(RecoverFailure::LeaseLost(err));
1275 }
1276 }
1277 }
1278 }
1279 }
1280 }
1281
1282 fn lease_timings(&self) -> crate::LeaseTimings {
1283 self.config.runtime_host.control.lease_timings
1284 }
1285
1286 async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
1287 self.config
1288 .process_registry
1289 .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
1290 .await
1291 }
1292
1293 pub async fn request_process_cancel(
1294 &self,
1295 process_id: &str,
1296 reason: Option<String>,
1297 ) -> Result<(), PluginError> {
1298 self.config
1299 .process_registry
1300 .append_event(
1301 process_id,
1302 crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
1303 )
1304 .await
1305 .map(|_| ())
1306 }
1307
1308 async fn runtime_for_registration(
1309 &self,
1310 registration: &ProcessRegistration,
1311 ) -> Result<LashRuntime, PluginError> {
1312 match registration.input.as_ref() {
1313 ProcessInput::SessionTurn { create_request, .. } => {
1314 Box::pin(self.runtime_for_session_turn(registration, create_request.as_ref())).await
1315 }
1316 ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
1317 Box::pin(self.runtime_for_process_env(registration)).await
1318 }
1319 ProcessInput::External { .. } => Err(PluginError::Session(format!(
1323 "process `{}` is externally-owned and has no execution runtime",
1324 registration.id
1325 ))),
1326 }
1327 }
1328
1329 async fn runtime_for_session_turn(
1330 &self,
1331 registration: &ProcessRegistration,
1332 create_request: &crate::SessionCreateRequest,
1333 ) -> Result<LashRuntime, PluginError> {
1334 let mut policy = create_request
1335 .policy
1336 .clone()
1337 .unwrap_or_else(|| self.config.session_policy.clone());
1338 if policy.recorded_provider_id().is_empty() {
1339 policy.provider_id = self.config.session_policy.provider_id.clone();
1340 }
1341 self.build_process_runtime(
1342 crate::process_runtime_session_ids(®istration.id)[1].clone(),
1343 policy,
1344 create_request.plugin_options.clone(),
1345 "session turn request",
1346 )
1347 .await
1348 }
1349
1350 async fn runtime_for_process_env(
1351 &self,
1352 registration: &ProcessRegistration,
1353 ) -> Result<LashRuntime, PluginError> {
1354 let Some(env_ref) = registration.env_ref.as_ref() else {
1355 return Err(PluginError::Session(format!(
1356 "process `{}` is missing a captured execution env",
1357 registration.id
1358 )));
1359 };
1360 let env = crate::load_process_execution_env(
1361 self.config
1362 .runtime_host
1363 .durability
1364 .process_env_store
1365 .as_ref(),
1366 env_ref,
1367 )
1368 .await?;
1369 self.build_process_runtime(
1370 crate::process_runtime_session_ids(®istration.id)[0].clone(),
1371 env.policy,
1372 env.plugin_options,
1373 env_ref.as_str(),
1374 )
1375 .await
1376 }
1377
1378 async fn build_process_runtime(
1379 &self,
1380 session_id: String,
1381 policy: crate::SessionPolicy,
1382 plugin_options: crate::PluginOptions,
1383 source_label: &str,
1384 ) -> Result<LashRuntime, PluginError> {
1385 let attachment_manifest_store = self
1386 .config
1387 .session_store_factory
1388 .create_store(&crate::SessionStoreCreateRequest {
1389 session_id: session_id.clone(),
1390 relation: crate::SessionRelation::default(),
1391 policy: policy.clone(),
1392 })
1393 .await
1394 .map_err(|err| {
1395 PluginError::Session(format!(
1396 "failed to open process attachment owner store for `{session_id}`: {err}"
1397 ))
1398 })?;
1399 let store = Arc::new(InMemorySessionStore::default());
1403 let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
1404 if let Some(hub) = self.config.process_change_hub.clone() {
1405 ProcessWorkDriver::from_watched(
1406 Arc::clone(&self.config.process_registry),
1407 hub,
1408 Arc::new(crate::InlineProcessRunHandle::new(self.clone())),
1409 )
1410 } else {
1411 ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
1412 }
1413 });
1414 let mut builder = EmbeddedRuntimeBuilder::new()
1415 .with_session_id(session_id.to_string())
1416 .with_plugin_host(self.config.plugin_host.as_ref().clone())
1417 .with_runtime_host(self.config.runtime_host.clone())
1418 .with_policy(policy)
1419 .with_plugin_options(plugin_options)
1420 .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
1421 .with_trigger_store(Arc::clone(&self.config.trigger_store))
1422 .with_process_registry(Arc::clone(&self.config.process_registry))
1423 .with_process_work_driver(process_work_driver)
1424 .with_residency(self.config.residency)
1425 .with_attachment_manifest_store(attachment_manifest_store)
1426 .with_store(store);
1427 if let Some(driver) = self.config.queued_work_driver.clone() {
1428 builder = builder.with_queued_work_driver(driver);
1429 }
1430 builder.build().await.map_err(|err| {
1431 PluginError::Session(format!(
1432 "failed to build process worker runtime for {source_label}: {err}"
1433 ))
1434 })
1435 }
1436
1437 fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
1446 if self
1447 .config
1448 .runtime_host
1449 .control
1450 .effect_host
1451 .durability_tier()
1452 != crate::DurabilityTier::Durable
1453 {
1454 return Ok(());
1455 }
1456 let require = |facet: crate::DurableStoreFacet| {
1457 PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
1458 };
1459 if self
1460 .config
1461 .runtime_host
1462 .durability
1463 .attachment_store
1464 .persistence()
1465 .durability_tier()
1466 != crate::DurabilityTier::Durable
1467 {
1468 return Err(require(crate::DurableStoreFacet::AttachmentStore));
1469 }
1470 if self
1471 .config
1472 .runtime_host
1473 .durability
1474 .process_env_store
1475 .durability_tier()
1476 != crate::DurabilityTier::Durable
1477 {
1478 return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
1479 }
1480 if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
1481 return Err(require(crate::DurableStoreFacet::SessionStore));
1482 }
1483 if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
1484 return Err(require(crate::DurableStoreFacet::ProcessRegistry));
1485 }
1486 if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
1487 return Err(require(crate::DurableStoreFacet::TriggerStore));
1488 }
1489 Ok(())
1490 }
1491
1492 fn ensure_stable_process_id(
1500 &self,
1501 registration: &ProcessRegistration,
1502 ) -> Result<(), PluginError> {
1503 if registration.id.trim().is_empty() {
1504 return Err(PluginError::Session(
1505 crate::RuntimeError::missing_process_execution_id().to_string(),
1506 ));
1507 }
1508 Ok(())
1509 }
1510}
1511
1512fn registration_from_record(record: ProcessRecord) -> ProcessRegistration {
1515 ProcessRegistration {
1516 id: record.id,
1517 input: record.input,
1518 disposition: record.disposition,
1519 identity: record.identity,
1520 event_types: record.event_types,
1521 provenance: record.provenance,
1522 env_ref: record.env_ref,
1523 wake_target: record.wake_target,
1524 }
1525}
1526
1527#[cfg(test)]
1528mod boundary_tests;
1529#[cfg(test)]
1530mod recovery_tests;