Skip to main content

lash_core/runtime/process/
awaiter.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use tokio::sync::watch;
6
7use super::events::{
8    ProcessAwaitOutput, ProcessCompletionAuthority, ProcessEvent, ProcessEventAppendRequest,
9    ProcessEventAppendResult,
10};
11use super::model::{
12    AbandonRequest, ProcessChangeCursor, ProcessExternalRef, ProcessHandleDescriptor,
13    ProcessHandleGrant, ProcessHandleGrantEntry, ProcessLease, ProcessLeaseClaimOutcome,
14    ProcessLeaseCompletion, ProcessListFilter, ProcessRecord, ProcessRegistration,
15    ProcessSessionDeleteReport, ProcessStarted, SessionScope, WaitState,
16};
17use super::registry::{ProcessPruneReport, ProcessRegistry};
18use crate::PluginError;
19
20const AWAIT_BACKOFF_MIN: Duration = Duration::from_millis(25);
21const AWAIT_BACKOFF_MAX: Duration = Duration::from_secs(1);
22
23#[derive(Clone, Default)]
24pub struct ProcessChangeHub {
25    inner: Arc<Mutex<HashMap<String, watch::Sender<u64>>>>,
26}
27
28impl ProcessChangeHub {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Subscribe before reading a process row. The receiver carries only a
34    /// version counter; waiters always re-read the registry after a bump.
35    pub fn subscribe(&self, process_id: &str) -> watch::Receiver<u64> {
36        let mut guard = self.inner.lock().expect("process change hub lock poisoned");
37        guard
38            .entry(process_id.to_string())
39            .or_insert_with(|| {
40                let (tx, _rx) = watch::channel(0);
41                tx
42            })
43            .subscribe()
44    }
45
46    pub fn notify(&self, process_id: &str) {
47        let mut guard = self.inner.lock().expect("process change hub lock poisoned");
48        let mut remove = false;
49        if let Some(tx) = guard.get(process_id) {
50            if tx.receiver_count() == 0 {
51                remove = true;
52            } else {
53                let next = (*tx.borrow()).wrapping_add(1);
54                if tx.send(next).is_err() {
55                    remove = true;
56                }
57            }
58        }
59        if remove {
60            guard.remove(process_id);
61        }
62    }
63
64    #[cfg(test)]
65    fn tracked_processes(&self) -> usize {
66        self.inner
67            .lock()
68            .expect("process change hub lock poisoned")
69            .len()
70    }
71}
72
73/// Host-facing, best-effort push of each appended process event.
74///
75/// A sink is an optional freshness feed, **never a source of truth.** The
76/// durable event log ([`ProcessRegistry::events_after`]) is the only complete
77/// record; a sink lets a host observe appends promptly without polling, but it
78/// makes no delivery promise.
79///
80/// # Contract
81///
82/// - **Best-effort freshness, never truth.** [`WatchedProcessRegistry`] calls
83///   [`emit`](Self::emit) after a successful `append_event`, in that pod's
84///   per-process append order. There is no buffering, no retry, and no
85///   delivery guarantee across pod crashes or restarts: an event that was
86///   appended durably may never reach the sink (e.g. the pod died between the
87///   durable write and the emit). Consumers that need completeness reconcile
88///   from `events_after` — the durable log is authoritative — typically at
89///   terminal time.
90/// - **Terminal events are deliberately NOT emitted through the sink.**
91///   [`ProcessRegistry::complete_process`] and
92///   [`ProcessRegistry::complete_process_with_lease`] append terminal events via
93///   the *inner* registry internally, so the decorator never observes them as
94///   `append_event` calls and never emits them. Do not wait on the sink for
95///   completion: terminal observation rides
96///   [`ProcessWorkDriver::await_terminal`](crate::ProcessWorkDriver::await_terminal)
97///   (see ADR 0016), which reads the durable terminal state.
98/// - **Emission cannot fail the write.** `emit` returns `()`, so a sink can
99///   never fail or roll back an append; the durable write has already
100///   committed by the time `emit` runs. But the decorator *awaits* `emit`
101///   inline on the append path, so a slow sink slows every append. Implementors
102///   must return fast: hand any real I/O off to a channel or background task
103///   internally rather than blocking inside `emit`.
104///
105/// # Example: offload to a channel
106///
107/// A sink must return fast, so a real implementation hands each event to a
108/// channel and does its projection/logging on a consumer task. Dropping on a
109/// full channel is the correct best-effort behavior — the durable log, read via
110/// `events_after`, remains the reconcile source.
111///
112/// ```
113/// use lash_core::{ProcessEvent, ProcessEventSink};
114/// use tokio::sync::mpsc;
115///
116/// struct ChannelSink {
117///     tx: mpsc::Sender<ProcessEvent>,
118/// }
119///
120/// #[async_trait::async_trait]
121/// impl ProcessEventSink for ChannelSink {
122///     async fn emit(&self, event: &ProcessEvent) {
123///         // Non-blocking: drop on a full channel rather than slow the append.
124///         let _ = self.tx.try_send(event.clone());
125///     }
126/// }
127/// ```
128#[async_trait::async_trait]
129pub trait ProcessEventSink: Send + Sync {
130    /// Observe one appended process event. Best-effort; see the trait contract.
131    ///
132    /// Must be fast and non-blocking — offload I/O to a channel/task internally.
133    async fn emit(&self, event: &ProcessEvent);
134}
135
136/// [`ProcessRegistry`] decorator: publishes in-process change ticks on every
137/// mutation (so [`ProcessAwaiter`] wakes without polling) and, when a
138/// [`ProcessEventSink`] is installed, emits each appended event to it.
139///
140/// The sink is installed once at wrap time via
141/// [`watch_process_registry_with_sink`]; there is no post-hoc mutation and no
142/// double-wrapping.
143struct WatchedProcessRegistry {
144    inner: Arc<dyn ProcessRegistry>,
145    hub: ProcessChangeHub,
146    sink: Option<Arc<dyn ProcessEventSink>>,
147}
148
149/// Wrap `inner` in a [`WatchedProcessRegistry`] with no event sink.
150///
151/// The decorated handle publishes change ticks to the returned
152/// [`ProcessChangeHub`]. Use [`watch_process_registry_with_sink`] to also feed a
153/// host-facing [`ProcessEventSink`].
154pub fn watch_process_registry(
155    inner: Arc<dyn ProcessRegistry>,
156) -> (Arc<dyn ProcessRegistry>, ProcessChangeHub) {
157    watch_process_registry_with_sink(inner, None)
158}
159
160/// Wrap `inner` in a [`WatchedProcessRegistry`], optionally installing a
161/// [`ProcessEventSink`] that receives every appended event.
162///
163/// The sink is best-effort freshness, not truth — see [`ProcessEventSink`].
164pub fn watch_process_registry_with_sink(
165    inner: Arc<dyn ProcessRegistry>,
166    sink: Option<Arc<dyn ProcessEventSink>>,
167) -> (Arc<dyn ProcessRegistry>, ProcessChangeHub) {
168    let hub = ProcessChangeHub::new();
169    (
170        Arc::new(WatchedProcessRegistry {
171            inner,
172            hub: hub.clone(),
173            sink,
174        }),
175        hub,
176    )
177}
178
179/// Core waiter for process terminal state and events (ADR 0016).
180///
181/// The awaiter is the store-only fallback that
182/// [`ProcessWorkDriver`](crate::ProcessWorkDriver) uses when no engine-native
183/// [`ProcessAttach`] owns the wait. It performs narrow point reads
184/// (`get_process`, `events_after`) and, when constructed with a
185/// [`ProcessChangeHub`], wakes promptly on local mutations instead of polling.
186/// Callers still bound every wait with [`tokio::time::timeout`].
187#[derive(Clone)]
188pub struct ProcessAwaiter {
189    registry: Arc<dyn ProcessRegistry>,
190    hub: Option<ProcessChangeHub>,
191}
192
193impl ProcessAwaiter {
194    /// Hub-backed awaiter: local mutations published to `hub` wake waiters
195    /// without database polling. This is what a [`WatchedProcessRegistry`]
196    /// wrapping provides via [`watch_process_registry`].
197    pub fn new(registry: Arc<dyn ProcessRegistry>, hub: ProcessChangeHub) -> Self {
198        Self {
199            registry,
200            hub: Some(hub),
201        }
202    }
203
204    /// Hubless awaiter: correct without any change signal, using only the
205    /// bounded backoff point-read loop (25ms floor, doubling, 1s cap). Use when
206    /// the registry is not wrapped in-process — e.g. a store-only test.
207    pub fn polling(registry: Arc<dyn ProcessRegistry>) -> Self {
208        Self {
209            registry,
210            hub: None,
211        }
212    }
213
214    /// Resolve once `process_id` is terminal, returning its outcome. See
215    /// [`ProcessWorkDriver::await_terminal`](crate::ProcessWorkDriver::await_terminal)
216    /// for the timeout-bounding contract.
217    pub async fn await_terminal(
218        &self,
219        process_id: &str,
220    ) -> Result<ProcessAwaitOutput, PluginError> {
221        if let Some(output) = self.read_terminal(process_id).await? {
222            return Ok(output);
223        }
224        crate::runtime::process_worker::release_process_execution_permit_while(
225            self.await_terminal_inner(process_id),
226        )
227        .await
228    }
229
230    async fn await_terminal_inner(
231        &self,
232        process_id: &str,
233    ) -> Result<ProcessAwaitOutput, PluginError> {
234        let mut backoff = AWAIT_BACKOFF_MIN;
235        if let Some(hub) = self.hub.as_ref() {
236            let mut rx = hub.subscribe(process_id);
237            loop {
238                if let Some(output) = self.read_terminal(process_id).await? {
239                    return Ok(output);
240                }
241                tokio::select! {
242                    changed = rx.changed() => {
243                        match changed {
244                            Ok(()) => backoff = AWAIT_BACKOFF_MIN,
245                            // Sender dropped (unreachable today given the hub
246                            // GC invariant, but latent): a dead receiver would
247                            // otherwise fire immediately on every loop turn.
248                            // Stop selecting on it and degrade to the
249                            // sleep-only backoff loop below.
250                            Err(_) => break,
251                        }
252                    }
253                    _ = tokio::time::sleep(backoff) => {
254                        backoff = next_backoff(backoff);
255                    }
256                }
257            }
258        }
259        loop {
260            if let Some(output) = self.read_terminal(process_id).await? {
261                return Ok(output);
262            }
263            tokio::time::sleep(backoff).await;
264            backoff = next_backoff(backoff);
265        }
266    }
267
268    /// Resolve with the first `event_type` event on `process_id` past
269    /// `after_sequence`. Historical matches resolve immediately.
270    pub async fn await_event(
271        &self,
272        process_id: &str,
273        event_type: &str,
274        after_sequence: u64,
275    ) -> Result<ProcessEvent, PluginError> {
276        if let Some(event) = self
277            .read_event(process_id, event_type, after_sequence)
278            .await?
279        {
280            return Ok(event);
281        }
282        crate::runtime::process_worker::release_process_execution_permit_while(
283            self.await_event_inner(process_id, event_type, after_sequence),
284        )
285        .await
286    }
287
288    async fn await_event_inner(
289        &self,
290        process_id: &str,
291        event_type: &str,
292        after_sequence: u64,
293    ) -> Result<ProcessEvent, PluginError> {
294        let mut backoff = AWAIT_BACKOFF_MIN;
295        if let Some(hub) = self.hub.as_ref() {
296            let mut rx = hub.subscribe(process_id);
297            loop {
298                if let Some(event) = self
299                    .read_event(process_id, event_type, after_sequence)
300                    .await?
301                {
302                    return Ok(event);
303                }
304                tokio::select! {
305                    changed = rx.changed() => {
306                        match changed {
307                            Ok(()) => backoff = AWAIT_BACKOFF_MIN,
308                            // Sender dropped (unreachable today given the hub
309                            // GC invariant, but latent): a dead receiver would
310                            // otherwise fire immediately on every loop turn.
311                            // Stop selecting on it and degrade to the
312                            // sleep-only backoff loop below.
313                            Err(_) => break,
314                        }
315                    }
316                    _ = tokio::time::sleep(backoff) => {
317                        backoff = next_backoff(backoff);
318                    }
319                }
320            }
321        }
322        loop {
323            if let Some(event) = self
324                .read_event(process_id, event_type, after_sequence)
325                .await?
326            {
327                return Ok(event);
328            }
329            tokio::time::sleep(backoff).await;
330            backoff = next_backoff(backoff);
331        }
332    }
333
334    async fn read_terminal(
335        &self,
336        process_id: &str,
337    ) -> Result<Option<ProcessAwaitOutput>, PluginError> {
338        let record = self
339            .registry
340            .get_process(process_id)
341            .await
342            .ok_or_else(|| PluginError::Session(format!("unknown process `{process_id}`")))?;
343        Ok(record.status.await_output().cloned())
344    }
345
346    async fn read_event(
347        &self,
348        process_id: &str,
349        event_type: &str,
350        after_sequence: u64,
351    ) -> Result<Option<ProcessEvent>, PluginError> {
352        Ok(self
353            .registry
354            .events_after(process_id, after_sequence)
355            .await?
356            .into_iter()
357            .find(|event| event.event_type == event_type))
358    }
359}
360
361fn next_backoff(current: Duration) -> Duration {
362    current.saturating_mul(2).min(AWAIT_BACKOFF_MAX)
363}
364
365#[async_trait::async_trait]
366pub trait ProcessAttach: Send + Sync {
367    async fn await_terminal(&self, process_id: &str) -> Result<ProcessAwaitOutput, PluginError>;
368}
369
370#[async_trait::async_trait]
371impl ProcessRegistry for WatchedProcessRegistry {
372    fn durability_tier(&self) -> crate::DurabilityTier {
373        self.inner.durability_tier()
374    }
375
376    async fn register_process(
377        &self,
378        registration: ProcessRegistration,
379    ) -> Result<ProcessRecord, PluginError> {
380        let process_id = registration.id.clone();
381        let record = self.inner.register_process(registration).await?;
382        self.hub.notify(&process_id);
383        Ok(record)
384    }
385
386    async fn put_segment_handover(
387        &self,
388        process_id: &str,
389        handover: crate::PersistedSegmentHandover,
390    ) -> Result<(), PluginError> {
391        self.inner.put_segment_handover(process_id, handover).await
392    }
393
394    async fn get_segment_handover(
395        &self,
396        process_id: &str,
397        segment_ordinal: u64,
398    ) -> Result<Option<crate::PersistedSegmentHandover>, PluginError> {
399        self.inner
400            .get_segment_handover(process_id, segment_ordinal)
401            .await
402    }
403
404    async fn latest_segment_handover(
405        &self,
406        process_id: &str,
407    ) -> Result<Option<crate::PersistedSegmentHandover>, PluginError> {
408        self.inner.latest_segment_handover(process_id).await
409    }
410
411    async fn delete_segment_handovers(&self, process_id: &str) -> Result<(), PluginError> {
412        self.inner.delete_segment_handovers(process_id).await
413    }
414
415    async fn set_external_ref(
416        &self,
417        process_id: &str,
418        external_ref: ProcessExternalRef,
419    ) -> Result<ProcessRecord, PluginError> {
420        let record = self
421            .inner
422            .set_external_ref(process_id, external_ref)
423            .await?;
424        self.hub.notify(process_id);
425        Ok(record)
426    }
427
428    async fn grant_handle(
429        &self,
430        session_scope: &SessionScope,
431        process_id: &str,
432        descriptor: ProcessHandleDescriptor,
433    ) -> Result<ProcessHandleGrant, PluginError> {
434        self.inner
435            .grant_handle(session_scope, process_id, descriptor)
436            .await
437    }
438
439    async fn revoke_handle(
440        &self,
441        session_scope: &SessionScope,
442        process_id: &str,
443    ) -> Result<(), PluginError> {
444        self.inner.revoke_handle(session_scope, process_id).await
445    }
446
447    async fn transfer_handle_grants(
448        &self,
449        from_scope: &SessionScope,
450        to_scope: &SessionScope,
451        process_ids: &[String],
452    ) -> Result<(), PluginError> {
453        self.inner
454            .transfer_handle_grants(from_scope, to_scope, process_ids)
455            .await
456    }
457
458    async fn list_handle_grants(
459        &self,
460        session_scope: &SessionScope,
461    ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
462        self.inner.list_handle_grants(session_scope).await
463    }
464
465    async fn list_live_handle_grants(
466        &self,
467        session_scope: &SessionScope,
468    ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
469        self.inner.list_live_handle_grants(session_scope).await
470    }
471
472    async fn has_handle_grant(
473        &self,
474        session_scope: &SessionScope,
475        process_id: &str,
476    ) -> Result<bool, PluginError> {
477        self.inner.has_handle_grant(session_scope, process_id).await
478    }
479
480    async fn handle_grants_for_process(
481        &self,
482        process_id: &str,
483    ) -> Result<Vec<ProcessHandleGrant>, PluginError> {
484        self.inner.handle_grants_for_process(process_id).await
485    }
486
487    async fn delete_session_process_state(
488        &self,
489        session_id: &str,
490    ) -> Result<ProcessSessionDeleteReport, PluginError> {
491        self.inner.delete_session_process_state(session_id).await
492    }
493
494    async fn append_event(
495        &self,
496        process_id: &str,
497        request: ProcessEventAppendRequest,
498    ) -> Result<ProcessEventAppendResult, PluginError> {
499        let result = self.inner.append_event(process_id, request).await?;
500        self.hub.notify(process_id);
501        // Best-effort freshness after the durable append: the write already
502        // committed, so the sink cannot fail it. Terminal appends never reach
503        // here — `complete_process` writes them through the inner registry.
504        if let Some(sink) = self.sink.as_ref() {
505            sink.emit(&result.event).await;
506        }
507        Ok(result)
508    }
509
510    async fn events_after(
511        &self,
512        process_id: &str,
513        after_sequence: u64,
514    ) -> Result<Vec<ProcessEvent>, PluginError> {
515        self.inner.events_after(process_id, after_sequence).await
516    }
517
518    async fn count_events_through(
519        &self,
520        process_id: &str,
521        event_type: &str,
522        up_to_sequence: u64,
523    ) -> Result<u64, PluginError> {
524        self.inner
525            .count_events_through(process_id, event_type, up_to_sequence)
526            .await
527    }
528
529    async fn recent_events(
530        &self,
531        process_id: &str,
532        limit: usize,
533    ) -> Result<Vec<ProcessEvent>, PluginError> {
534        self.inner.recent_events(process_id, limit).await
535    }
536
537    async fn wake_events_after(
538        &self,
539        process_id: &str,
540        after_sequence: u64,
541    ) -> Result<Vec<ProcessEvent>, PluginError> {
542        self.inner
543            .wake_events_after(process_id, after_sequence)
544            .await
545    }
546
547    async fn complete_process(
548        &self,
549        process_id: &str,
550        await_output: ProcessAwaitOutput,
551        authority: ProcessCompletionAuthority,
552    ) -> Result<ProcessRecord, PluginError> {
553        let record = self
554            .inner
555            .complete_process(process_id, await_output, authority)
556            .await?;
557        self.hub.notify(process_id);
558        Ok(record)
559    }
560
561    async fn complete_process_with_lease(
562        &self,
563        lease: &ProcessLease,
564        await_output: ProcessAwaitOutput,
565    ) -> Result<ProcessRecord, PluginError> {
566        let record = self
567            .inner
568            .complete_process_with_lease(lease, await_output)
569            .await?;
570        self.hub.notify(&lease.process_id);
571        Ok(record)
572    }
573
574    async fn record_first_started(
575        &self,
576        process_id: &str,
577        started: ProcessStarted,
578    ) -> Result<ProcessRecord, PluginError> {
579        let record = self.inner.record_first_started(process_id, started).await?;
580        self.hub.notify(process_id);
581        Ok(record)
582    }
583
584    async fn request_process_abandon(
585        &self,
586        process_id: &str,
587        request: AbandonRequest,
588    ) -> Result<ProcessRecord, PluginError> {
589        let record = self
590            .inner
591            .request_process_abandon(process_id, request)
592            .await?;
593        self.hub.notify(process_id);
594        Ok(record)
595    }
596
597    async fn set_process_wait(
598        &self,
599        process_id: &str,
600        wait: WaitState,
601    ) -> Result<ProcessRecord, PluginError> {
602        let record = self.inner.set_process_wait(process_id, wait).await?;
603        self.hub.notify(process_id);
604        Ok(record)
605    }
606
607    async fn clear_process_wait(&self, process_id: &str) -> Result<ProcessRecord, PluginError> {
608        let record = self.inner.clear_process_wait(process_id).await?;
609        self.hub.notify(process_id);
610        Ok(record)
611    }
612
613    async fn get_process(&self, process_id: &str) -> Option<ProcessRecord> {
614        self.inner.get_process(process_id).await
615    }
616
617    async fn try_get_process(
618        &self,
619        process_id: &str,
620    ) -> Result<Option<ProcessRecord>, PluginError> {
621        self.inner.try_get_process(process_id).await
622    }
623
624    async fn list_processes(
625        &self,
626        filter: &ProcessListFilter,
627    ) -> Result<Vec<ProcessRecord>, PluginError> {
628        self.inner.list_processes(filter).await
629    }
630
631    async fn processes_changed_since(
632        &self,
633        cursor: ProcessChangeCursor,
634        limit: usize,
635    ) -> Result<(Vec<ProcessRecord>, ProcessChangeCursor), PluginError> {
636        self.inner.processes_changed_since(cursor, limit).await
637    }
638
639    async fn ack_wake(&self, process_id: &str, sequence: u64) -> Result<(), PluginError> {
640        self.inner.ack_wake(process_id, sequence).await?;
641        self.hub.notify(process_id);
642        Ok(())
643    }
644
645    async fn list_non_terminal(&self) -> Result<Vec<ProcessRecord>, PluginError> {
646        self.inner.list_non_terminal().await
647    }
648
649    async fn live_reference_summary(
650        &self,
651    ) -> Result<Vec<super::references::ProcessLiveReferenceSummary>, PluginError> {
652        self.inner.live_reference_summary().await
653    }
654
655    async fn claim_process_lease(
656        &self,
657        process_id: &str,
658        owner: &crate::LeaseOwnerIdentity,
659        lease_ttl_ms: u64,
660    ) -> Result<ProcessLeaseClaimOutcome, PluginError> {
661        self.inner
662            .claim_process_lease(process_id, owner, lease_ttl_ms)
663            .await
664    }
665
666    async fn reclaim_process_lease(
667        &self,
668        process_id: &str,
669        owner: &crate::LeaseOwnerIdentity,
670        observed_holder: &ProcessLease,
671        lease_ttl_ms: u64,
672    ) -> Result<ProcessLeaseClaimOutcome, PluginError> {
673        self.inner
674            .reclaim_process_lease(process_id, owner, observed_holder, lease_ttl_ms)
675            .await
676    }
677
678    async fn renew_process_lease(
679        &self,
680        lease: &ProcessLease,
681        lease_ttl_ms: u64,
682    ) -> Result<ProcessLease, PluginError> {
683        self.inner.renew_process_lease(lease, lease_ttl_ms).await
684    }
685
686    async fn get_process_lease(
687        &self,
688        process_id: &str,
689    ) -> Result<Option<ProcessLease>, PluginError> {
690        self.inner.get_process_lease(process_id).await
691    }
692
693    async fn complete_process_lease(
694        &self,
695        completion: &ProcessLeaseCompletion,
696    ) -> Result<(), PluginError> {
697        self.inner.complete_process_lease(completion).await
698    }
699
700    async fn prune_terminal_processes(
701        &self,
702        cutoff_epoch_ms: u64,
703        filter: Option<ProcessListFilter>,
704        up_to_change_seq: Option<ProcessChangeCursor>,
705    ) -> Result<ProcessPruneReport, PluginError> {
706        // No hub bump: pruned rows are terminal, so any waiter on them resolved
707        // long ago (terminal state is durable and observed via the await seam).
708        self.inner
709            .prune_terminal_processes(cutoff_epoch_ms, filter, up_to_change_seq)
710            .await
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use std::sync::Arc;
717
718    use super::*;
719    use crate::{
720        ProcessInput, ProcessProvenance, ProcessRegistration, TestLocalProcessRegistry, ToolControl,
721    };
722
723    fn registration(process_id: &str) -> ProcessRegistration {
724        ProcessRegistration::new(
725            process_id,
726            ProcessInput::External {
727                metadata: serde_json::json!({}),
728            },
729            crate::RecoveryDisposition::ExternallyOwned,
730            ProcessProvenance::host(),
731        )
732    }
733
734    fn plain_event_type(name: &str) -> crate::ProcessEventType {
735        crate::ProcessEventType {
736            name: name.to_string(),
737            payload_schema: crate::LashSchema::any(),
738            semantics: crate::ProcessEventSemanticsSpec::default(),
739        }
740    }
741
742    fn registration_with_events(process_id: &str, event_types: &[&str]) -> ProcessRegistration {
743        registration(process_id)
744            .with_extra_event_types(event_types.iter().map(|name| plain_event_type(name)))
745    }
746
747    /// Records `(event_type, sequence)` in emit order for sink assertions.
748    #[derive(Clone, Default)]
749    struct CollectingSink {
750        events: Arc<Mutex<Vec<(String, u64)>>>,
751    }
752
753    impl CollectingSink {
754        fn collected(&self) -> Vec<(String, u64)> {
755            self.events.lock().expect("sink lock").clone()
756        }
757    }
758
759    #[async_trait::async_trait]
760    impl ProcessEventSink for CollectingSink {
761        async fn emit(&self, event: &ProcessEvent) {
762            self.events
763                .lock()
764                .expect("sink lock")
765                .push((event.event_type.clone(), event.sequence));
766        }
767    }
768
769    fn success(value: serde_json::Value) -> ProcessAwaitOutput {
770        ProcessAwaitOutput::Success {
771            value,
772            control: None::<ToolControl>,
773        }
774    }
775
776    /// ADR 0016 pins the awaiter's polling cadence: a 25ms floor, doubling
777    /// backoff, and a 1s cap. Changing any of the three alters every store-only
778    /// deployment's wait economics, so the exact schedule is asserted here.
779    #[test]
780    fn backoff_schedule_has_25ms_floor_doubling_to_1s_cap() {
781        assert_eq!(AWAIT_BACKOFF_MIN, Duration::from_millis(25));
782        assert_eq!(AWAIT_BACKOFF_MAX, Duration::from_secs(1));
783
784        let mut backoff = AWAIT_BACKOFF_MIN;
785        let mut schedule = vec![backoff];
786        while backoff < AWAIT_BACKOFF_MAX {
787            backoff = next_backoff(backoff);
788            schedule.push(backoff);
789        }
790        assert_eq!(
791            schedule,
792            [25, 50, 100, 200, 400, 800, 1000]
793                .into_iter()
794                .map(Duration::from_millis)
795                .collect::<Vec<_>>(),
796            "the backoff doubles from the 25ms floor and saturates at the 1s cap"
797        );
798        assert_eq!(
799            next_backoff(AWAIT_BACKOFF_MAX),
800            AWAIT_BACKOFF_MAX,
801            "the cap is absorbing"
802        );
803    }
804
805    /// ADR 0017: the decorator delegates `prune_terminal_processes` without a
806    /// hub bump — pruned rows are terminal, so their waiters resolved long ago
807    /// and a tick would only wake unrelated subscribers spuriously.
808    #[tokio::test]
809    async fn prune_through_decorator_does_not_bump_the_hub() {
810        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
811        let (registry, hub) = watch_process_registry(raw);
812        registry
813            .register_process(registration("proc-terminal"))
814            .await
815            .expect("register terminal");
816        registry
817            .complete_process(
818                "proc-terminal",
819                success(serde_json::json!("done")),
820                crate::ProcessCompletionAuthority::external_owner("test"),
821            )
822            .await
823            .expect("complete");
824        registry
825            .register_process(registration("proc-live"))
826            .await
827            .expect("register live");
828
829        // Subscribe after the mutations above so only post-subscription bumps
830        // are observable.
831        let mut terminal_rx = hub.subscribe("proc-terminal");
832        let mut live_rx = hub.subscribe("proc-live");
833        terminal_rx.mark_unchanged();
834        live_rx.mark_unchanged();
835
836        let report = registry
837            .prune_terminal_processes(u64::MAX, None, None)
838            .await
839            .expect("prune");
840        assert_eq!(report.pruned_processes, 1, "the terminal process pruned");
841
842        assert!(
843            !terminal_rx.has_changed().expect("terminal sender open"),
844            "prune must not bump the pruned process's hub entry"
845        );
846        assert!(
847            !live_rx.has_changed().expect("live sender open"),
848            "prune must not bump surviving processes' hub entries"
849        );
850    }
851
852    #[tokio::test]
853    async fn hub_subscribe_then_notify_wakes_and_gc_drops_empty_entry() {
854        let hub = ProcessChangeHub::new();
855        let mut rx = hub.subscribe("proc");
856        hub.notify("proc");
857        tokio::time::timeout(Duration::from_millis(100), rx.changed())
858            .await
859            .expect("notify should wake")
860            .expect("sender remains open");
861
862        drop(rx);
863        hub.notify("proc");
864        assert_eq!(hub.tracked_processes(), 0);
865    }
866
867    #[tokio::test]
868    async fn await_event_returns_historical_event_immediately() {
869        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
870        let (registry, hub) = watch_process_registry(raw);
871        registry
872            .register_process(registration("proc"))
873            .await
874            .expect("register");
875        let appended = registry
876            .append_event(
877                "proc",
878                ProcessEventAppendRequest::cancel_requested("proc", Some("stop".to_string())),
879            )
880            .await
881            .expect("append");
882
883        let event = ProcessAwaiter::new(Arc::clone(&registry), hub)
884            .await_event("proc", "process.cancel_requested", 0)
885            .await
886            .expect("await event");
887        assert_eq!(event.sequence, appended.event.sequence);
888    }
889
890    #[tokio::test]
891    async fn await_terminal_unknown_process_errors() {
892        let registry = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
893        let err = ProcessAwaiter::polling(registry)
894            .await_terminal("missing")
895            .await
896            .expect_err("unknown process should error");
897        assert!(err.to_string().contains("unknown process `missing`"));
898    }
899
900    #[tokio::test]
901    async fn polling_awaiter_resolves_via_backoff() {
902        let registry = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
903        registry
904            .register_process(registration("proc"))
905            .await
906            .expect("register");
907        let writer = Arc::clone(&registry);
908        crate::task::spawn(async move {
909            tokio::time::sleep(Duration::from_millis(10)).await;
910            writer
911                .complete_process(
912                    "proc",
913                    success(serde_json::json!({ "ok": true })),
914                    crate::ProcessCompletionAuthority::external_owner("test"),
915                )
916                .await
917                .expect("complete");
918        });
919
920        let output = tokio::time::timeout(
921            Duration::from_secs(1),
922            ProcessAwaiter::polling(registry).await_terminal("proc"),
923        )
924        .await
925        .expect("polling await timeout")
926        .expect("await terminal");
927        assert_eq!(output, success(serde_json::json!({ "ok": true })));
928    }
929
930    #[tokio::test]
931    async fn watched_awaiter_observes_terminal_without_lost_wakeup() {
932        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
933        let (registry, hub) = watch_process_registry(raw);
934        registry
935            .register_process(registration("proc"))
936            .await
937            .expect("register");
938        let awaiter = ProcessAwaiter::new(Arc::clone(&registry), hub);
939        let waiter = crate::task::spawn(async move { awaiter.await_terminal("proc").await });
940        registry
941            .complete_process(
942                "proc",
943                success(serde_json::json!("done")),
944                crate::ProcessCompletionAuthority::external_owner("test"),
945            )
946            .await
947            .expect("complete");
948
949        let output = tokio::time::timeout(Duration::from_millis(200), waiter)
950            .await
951            .expect("watched await timeout")
952            .expect("join")
953            .expect("await terminal");
954        assert_eq!(output, success(serde_json::json!("done")));
955    }
956
957    #[tokio::test]
958    async fn watched_registry_bumps_on_mutations() {
959        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
960        let (registry, hub) = watch_process_registry(raw);
961        let mut rx = hub.subscribe("proc");
962        registry
963            .register_process(registration("proc"))
964            .await
965            .expect("register");
966        tokio::time::timeout(Duration::from_millis(100), rx.changed())
967            .await
968            .expect("register bump")
969            .expect("sender remains open");
970
971        registry
972            .append_event(
973                "proc",
974                ProcessEventAppendRequest::cancel_requested("proc", None),
975            )
976            .await
977            .expect("append");
978        tokio::time::timeout(Duration::from_millis(100), rx.changed())
979            .await
980            .expect("append bump")
981            .expect("sender remains open");
982    }
983
984    #[tokio::test]
985    async fn sink_receives_appended_events_in_order() {
986        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
987        let sink = CollectingSink::default();
988        let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
989        registry
990            .register_process(registration_with_events(
991                "proc",
992                &["producer.a", "producer.b"],
993            ))
994            .await
995            .expect("register");
996        registry
997            .append_event(
998                "proc",
999                ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1000            )
1001            .await
1002            .expect("append a");
1003        registry
1004            .append_event(
1005                "proc",
1006                ProcessEventAppendRequest::new("producer.b", serde_json::json!({})),
1007            )
1008            .await
1009            .expect("append b");
1010
1011        assert_eq!(
1012            sink.collected(),
1013            vec![("producer.a".to_string(), 1), ("producer.b".to_string(), 2)],
1014            "the sink must observe appended events after their write, in append order"
1015        );
1016    }
1017
1018    #[tokio::test]
1019    async fn sink_absent_leaves_appends_unchanged() {
1020        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1021        let (registry, _hub) = watch_process_registry_with_sink(raw, None);
1022        registry
1023            .register_process(registration_with_events("proc", &["producer.a"]))
1024            .await
1025            .expect("register");
1026        let appended = registry
1027            .append_event(
1028                "proc",
1029                ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1030            )
1031            .await
1032            .expect("append succeeds with no sink installed");
1033        assert_eq!(appended.event.sequence, 1);
1034    }
1035
1036    #[tokio::test]
1037    async fn sink_not_invoked_for_complete_process_terminal_append() {
1038        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1039        let sink = CollectingSink::default();
1040        let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
1041        registry
1042            .register_process(registration_with_events("proc", &["producer.a"]))
1043            .await
1044            .expect("register");
1045        registry
1046            .append_event(
1047                "proc",
1048                ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1049            )
1050            .await
1051            .expect("explicit append");
1052        registry
1053            .complete_process(
1054                "proc",
1055                success(serde_json::json!("done")),
1056                crate::ProcessCompletionAuthority::external_owner("test"),
1057            )
1058            .await
1059            .expect("complete");
1060
1061        assert_eq!(
1062            sink.collected(),
1063            vec![("producer.a".to_string(), 1)],
1064            "complete_process appends its terminal event through the inner registry, so the \
1065             decorator never emits it to the sink"
1066        );
1067    }
1068
1069    #[tokio::test]
1070    async fn sink_present_still_bumps_hub_on_append() {
1071        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1072        let sink = CollectingSink::default();
1073        let (registry, hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink)));
1074        let mut rx = hub.subscribe("proc");
1075        registry
1076            .register_process(registration_with_events("proc", &["producer.a"]))
1077            .await
1078            .expect("register");
1079        tokio::time::timeout(Duration::from_millis(100), rx.changed())
1080            .await
1081            .expect("register bump")
1082            .expect("sender remains open");
1083        registry
1084            .append_event(
1085                "proc",
1086                ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1087            )
1088            .await
1089            .expect("append");
1090        tokio::time::timeout(Duration::from_millis(100), rx.changed())
1091            .await
1092            .expect("append bump with a sink installed")
1093            .expect("sender remains open");
1094    }
1095
1096    struct NoopRunHandle;
1097
1098    #[async_trait::async_trait]
1099    impl crate::ProcessRunHandle for NoopRunHandle {
1100        async fn claim_and_run_pending(&self) -> Result<(), PluginError> {
1101            Ok(())
1102        }
1103    }
1104
1105    struct PanicAttach;
1106
1107    #[async_trait::async_trait]
1108    impl ProcessAttach for PanicAttach {
1109        async fn await_terminal(
1110            &self,
1111            _process_id: &str,
1112        ) -> Result<ProcessAwaitOutput, PluginError> {
1113            panic!("attach should not be called for already-terminal process")
1114        }
1115    }
1116
1117    struct ErrorAttach;
1118
1119    #[async_trait::async_trait]
1120    impl ProcessAttach for ErrorAttach {
1121        async fn await_terminal(
1122            &self,
1123            _process_id: &str,
1124        ) -> Result<ProcessAwaitOutput, PluginError> {
1125            Err(PluginError::Session("attach failed".to_string()))
1126        }
1127    }
1128
1129    #[tokio::test]
1130    async fn driver_short_circuits_terminal_before_attach() {
1131        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1132        let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle))
1133            .with_attach(Arc::new(PanicAttach));
1134        let registry = driver.process_registry();
1135        registry
1136            .register_process(registration("proc"))
1137            .await
1138            .expect("register");
1139        registry
1140            .complete_process(
1141                "proc",
1142                success(serde_json::json!("ready")),
1143                crate::ProcessCompletionAuthority::external_owner("test"),
1144            )
1145            .await
1146            .expect("complete");
1147
1148        let output = driver.await_terminal("proc").await.expect("await terminal");
1149        assert_eq!(output, success(serde_json::json!("ready")));
1150    }
1151
1152    #[tokio::test]
1153    async fn driver_attach_errors_propagate_without_poll_fallback() {
1154        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1155        let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle))
1156            .with_attach(Arc::new(ErrorAttach));
1157        driver
1158            .process_registry()
1159            .register_process(registration("proc"))
1160            .await
1161            .expect("register");
1162
1163        let err = driver
1164            .await_terminal("proc")
1165            .await
1166            .expect_err("attach error should propagate");
1167        assert!(err.to_string().contains("attach failed"));
1168    }
1169
1170    struct CountingAttach {
1171        calls: Arc<std::sync::atomic::AtomicUsize>,
1172    }
1173
1174    #[async_trait::async_trait]
1175    impl ProcessAttach for CountingAttach {
1176        async fn await_terminal(
1177            &self,
1178            _process_id: &str,
1179        ) -> Result<ProcessAwaitOutput, PluginError> {
1180            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1181            Err(PluginError::Session(
1182                "attach must not be consulted for a terminal process".to_string(),
1183            ))
1184        }
1185    }
1186
1187    /// Sim-style race: many waiters attach to one process and completion fires
1188    /// while they are mid-flight between their subscribe and their first read.
1189    /// The change hub must resolve every one with identical output — no lost
1190    /// wakeups, no divergent results (ADR 0016).
1191    #[tokio::test]
1192    async fn concurrent_waiters_all_resolve_with_identical_output_on_completion() {
1193        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1194        let (registry, hub) = watch_process_registry(raw);
1195        registry
1196            .register_process(registration("proc"))
1197            .await
1198            .expect("register");
1199
1200        const WAITERS: usize = 16;
1201        let barrier = Arc::new(tokio::sync::Barrier::new(WAITERS + 1));
1202        let mut waiters = Vec::with_capacity(WAITERS);
1203        for _ in 0..WAITERS {
1204            let awaiter = ProcessAwaiter::new(Arc::clone(&registry), hub.clone());
1205            let barrier = Arc::clone(&barrier);
1206            waiters.push(crate::task::spawn(async move {
1207                barrier.wait().await;
1208                awaiter.await_terminal("proc").await
1209            }));
1210        }
1211        // Release every waiter, then complete at once so completion races their
1212        // first read and subscribe.
1213        barrier.wait().await;
1214        let output = success(serde_json::json!({ "raced": true }));
1215        registry
1216            .complete_process(
1217                "proc",
1218                output.clone(),
1219                crate::ProcessCompletionAuthority::external_owner("test"),
1220            )
1221            .await
1222            .expect("complete");
1223
1224        for waiter in waiters {
1225            let resolved = tokio::time::timeout(Duration::from_secs(2), waiter)
1226                .await
1227                .expect("each racing waiter resolves under 2s")
1228                .expect("join waiter")
1229                .expect("await terminal");
1230            assert_eq!(
1231                resolved, output,
1232                "every concurrent waiter resolves with identical terminal output"
1233            );
1234        }
1235    }
1236
1237    /// Sim-style restart/re-attach: a process completes while no waiter is
1238    /// attached; a later `await_terminal` resolves instantly through the
1239    /// registry short-circuit and never consults the engine attach (ADR 0016 —
1240    /// the terminal point-read precedes any attach hand-off).
1241    #[tokio::test]
1242    async fn driver_reattach_after_terminal_short_circuits_without_engine_call() {
1243        use std::sync::atomic::Ordering;
1244
1245        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1246        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1247        let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle)).with_attach(
1248            Arc::new(CountingAttach {
1249                calls: Arc::clone(&calls),
1250            }),
1251        );
1252        let registry = driver.process_registry();
1253        registry
1254            .register_process(registration("proc"))
1255            .await
1256            .expect("register");
1257        // Process reaches terminal with no waiter attached.
1258        let output = success(serde_json::json!("reattached"));
1259        registry
1260            .complete_process(
1261                "proc",
1262                output.clone(),
1263                crate::ProcessCompletionAuthority::external_owner("test"),
1264            )
1265            .await
1266            .expect("complete");
1267
1268        // A later await resolves via the registry short-circuit, instantly.
1269        let start = std::time::Instant::now();
1270        let resolved = driver.await_terminal("proc").await.expect("await terminal");
1271        assert_eq!(resolved, output);
1272        assert_eq!(
1273            calls.load(Ordering::SeqCst),
1274            0,
1275            "a terminal short-circuit must never call the engine attach"
1276        );
1277        assert!(
1278            start.elapsed() < Duration::from_millis(500),
1279            "a short-circuit resolves without any backoff wait"
1280        );
1281    }
1282
1283    /// Records seen vs. dropped emit sequences, dropping even sequences to model
1284    /// best-effort push loss.
1285    #[derive(Clone, Default)]
1286    struct LossySink {
1287        seen: Arc<Mutex<Vec<u64>>>,
1288        dropped: Arc<Mutex<Vec<u64>>>,
1289    }
1290
1291    #[async_trait::async_trait]
1292    impl ProcessEventSink for LossySink {
1293        async fn emit(&self, event: &ProcessEvent) {
1294            if event.sequence.is_multiple_of(2) {
1295                self.dropped.lock().expect("sink lock").push(event.sequence);
1296            } else {
1297                self.seen.lock().expect("sink lock").push(event.sequence);
1298            }
1299        }
1300    }
1301
1302    /// Sim-style sink loss: a sink that drops a fraction of emits still leaves
1303    /// the durable log complete. Reconciling from `events_after` at terminal
1304    /// recovers every event the push feed missed — ADR 0017's "push loss never
1305    /// loses truth".
1306    #[tokio::test]
1307    async fn lossy_sink_still_reconciles_complete_log_from_events_after() {
1308        let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1309        let sink = LossySink::default();
1310        let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
1311        registry
1312            .register_process(registration_with_events("proc", &["producer.step"]))
1313            .await
1314            .expect("register");
1315
1316        const EVENTS: u64 = 6;
1317        for _ in 0..EVENTS {
1318            registry
1319                .append_event(
1320                    "proc",
1321                    ProcessEventAppendRequest::new("producer.step", serde_json::json!({})),
1322                )
1323                .await
1324                .expect("append");
1325        }
1326        // The terminal event never rides the sink at all (ADR 0017): completion
1327        // observation is the await seam's job.
1328        registry
1329            .complete_process(
1330                "proc",
1331                success(serde_json::json!("done")),
1332                crate::ProcessCompletionAuthority::external_owner("test"),
1333            )
1334            .await
1335            .expect("complete");
1336
1337        // The push feed genuinely lost some events...
1338        assert!(
1339            !sink.dropped.lock().expect("sink lock").is_empty(),
1340            "the lossy sink must drop at least one emit for the scenario to be meaningful"
1341        );
1342        assert!(
1343            (sink.seen.lock().expect("sink lock").len() as u64) < EVENTS,
1344            "the sink observed fewer events than were appended"
1345        );
1346        // ...but the durable log is the complete, ordered truth.
1347        let reconciled = registry
1348            .events_after("proc", 0)
1349            .await
1350            .expect("events")
1351            .into_iter()
1352            .filter(|event| event.event_type == "producer.step")
1353            .map(|event| event.sequence)
1354            .collect::<Vec<_>>();
1355        assert_eq!(
1356            reconciled,
1357            (1..=EVENTS).collect::<Vec<_>>(),
1358            "events_after reconciles the complete non-terminal log despite push loss"
1359        );
1360    }
1361}