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