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