Skip to main content

tapes_harnesses/transcript/
trigger.rs

1//! The per-session push trigger.
2//!
3//! Extracted from a daemon client's transcript uploader. A client evaluates
4//! every tracked session on a timer and asks [`decide`] whether to push that
5//! session's transcript files now. Three things can trigger a push:
6//!
7//! * **Quiescence** — files changed since the last successful push and the newest
8//!   mtime is at least [`TriggerPolicy::quiescence`] old. The common case: a turn
9//!   finishes, the harness stops writing, the transcript lands half a minute
10//!   later.
11//! * **Periodic safety net** — files changed and at least
12//!   [`TriggerPolicy::periodic`] has passed since the last push (or since the
13//!   session was first seen). Covers sessions that write continuously and never
14//!   quiesce.
15//! * **Exit** — the harness process is gone. Final push, then the client retires
16//!   the session.
17//!
18//! # Why this is safe to get wrong in the eager direction
19//!
20//! The trigger only ever decides *when* to push, never *whether* the data is
21//! wanted, because the transcript-ingest endpoint is idempotent by construction:
22//! the server keys rows on a content hash of the records array, so re-pushing
23//! unchanged content answers `deduped` and a grown transcript appends a new
24//! version. That makes every retry, every duplicate tick, and every push after a
25//! client restart safe. The design leans on that instead of client-side
26//! cleverness — which is also why the fingerprint feeding
27//! [`TriggerInput::dirty`] is deliberately coarse (size + mtime): any drift
28//! re-pushes, and the server sorts it out.
29//!
30//! # What stays with the client
31//!
32//! Failure backoff. A client that cannot reach ingest must not hammer it, but
33//! *how long* to wait, how many times, and when to give up depend on how that
34//! client authenticates and what it can fall back to — so the client owns the
35//! backoff schedule and merely reports the result through
36//! [`TriggerInput::in_backoff`], which gates everything.
37
38use std::time::Duration;
39
40/// Default idle window after the last transcript write before a push.
41pub const DEFAULT_QUIESCENCE: Duration = Duration::from_secs(30);
42
43/// Default safety-net push interval for sessions that never quiesce.
44pub const DEFAULT_PERIODIC: Duration = Duration::from_secs(300);
45
46/// Suggested cadence for evaluating the trigger.
47///
48/// Not consumed by [`decide`] — a client owns its own timer — but part of the
49/// machine's design: at a 5 s tick a 30 s quiescence window resolves within
50/// 35 s, which is the latency budget the window was chosen against. A much
51/// coarser tick silently widens that budget.
52pub const DEFAULT_TICK: Duration = Duration::from_secs(5);
53
54/// Timing thresholds for [`decide`].
55///
56/// Separated from a client's own configuration — endpoints, credentials, backoff
57/// schedule — so the state machine can be exercised with synthetic values and so
58/// the two clients cannot drift on the thresholds that decide when a transcript
59/// becomes visible in tapes.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct TriggerPolicy {
62    /// Idle window after the last transcript write before a push.
63    pub quiescence: Duration,
64    /// Safety-net push interval for never-quiescent sessions.
65    pub periodic: Duration,
66}
67
68impl Default for TriggerPolicy {
69    fn default() -> Self {
70        Self {
71            quiescence: DEFAULT_QUIESCENCE,
72            periodic: DEFAULT_PERIODIC,
73        }
74    }
75}
76
77/// Why a push fired. Clients log this with every upload batch.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum PushReason {
80    /// The harness stopped writing for [`TriggerPolicy::quiescence`].
81    Quiescence,
82    /// [`TriggerPolicy::periodic`] elapsed with changes still unpushed.
83    Periodic,
84    /// The harness process is gone; this is the final push.
85    Exit,
86}
87
88impl PushReason {
89    /// Stable lower-case label for logs and metrics.
90    #[must_use]
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            Self::Quiescence => "quiescence",
94            Self::Periodic => "periodic",
95            Self::Exit => "exit",
96        }
97    }
98}
99
100/// Pure inputs to the per-session trigger decision, separated from the IO that
101/// gathers them so the state machine is unit-testable with synthetic values.
102#[derive(Debug, Clone, Copy)]
103pub struct TriggerInput {
104    /// Any file's fingerprint differs from the last successful push (including
105    /// files never pushed). See [`super::files::fingerprint`].
106    pub dirty: bool,
107    /// The harness process behind this session is no longer running it.
108    pub exited: bool,
109    /// Age of the newest mtime across the session's files. `None` when no files
110    /// exist yet — a session can be attributed on the wire before its first
111    /// transcript flush.
112    pub idle_for: Option<Duration>,
113    /// Time since the last successful push, falling back to time since the
114    /// session was first tracked, so the periodic net has a baseline before the
115    /// first push.
116    pub since_last_push: Duration,
117    /// A previous push failed and its backoff window is still open. The client
118    /// owns the schedule; this is only its verdict.
119    pub in_backoff: bool,
120}
121
122/// The trigger state machine.
123///
124/// Order matters, and each step earns its position: backoff gates everything (a
125/// failing endpoint must not be hammered on every tick), a clean session is
126/// never pushed (there is nothing new to say), exit outranks the timers (the
127/// final state should land promptly rather than waiting out a window the harness
128/// will never close), then quiescence, then the periodic net.
129#[must_use]
130pub fn decide(policy: &TriggerPolicy, input: &TriggerInput) -> Option<PushReason> {
131    if input.in_backoff {
132        return None;
133    }
134    if !input.dirty {
135        return None;
136    }
137    if input.exited {
138        return Some(PushReason::Exit);
139    }
140    if input.idle_for.is_some_and(|idle| idle >= policy.quiescence) {
141        return Some(PushReason::Quiescence);
142    }
143    if input.since_last_push >= policy.periodic {
144        return Some(PushReason::Periodic);
145    }
146    None
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::expect_used)]
151mod tests {
152    use super::*;
153
154    /// A live, dirty, just-written session: the baseline every case below
155    /// perturbs one field of.
156    fn base_input() -> TriggerInput {
157        TriggerInput {
158            dirty: true,
159            exited: false,
160            idle_for: Some(Duration::from_secs(0)),
161            since_last_push: Duration::from_secs(0),
162            in_backoff: false,
163        }
164    }
165
166    /// Carried over from the uploader's clean-session test.
167    #[test]
168    fn clean_session_never_pushes() {
169        let input = TriggerInput {
170            dirty: false,
171            exited: false,
172            idle_for: Some(Duration::from_secs(3600)),
173            since_last_push: Duration::from_secs(3600),
174            ..base_input()
175        };
176        assert_eq!(decide(&TriggerPolicy::default(), &input), None);
177    }
178
179    /// Carried over from the uploader's quiescence-window test.
180    #[test]
181    fn quiescence_fires_after_idle_window() {
182        let policy = TriggerPolicy::default();
183        let input = TriggerInput {
184            idle_for: Some(Duration::from_secs(31)),
185            ..base_input()
186        };
187        assert_eq!(decide(&policy, &input), Some(PushReason::Quiescence));
188        // Still writing → no push yet.
189        let busy = TriggerInput {
190            idle_for: Some(Duration::from_secs(5)),
191            ..base_input()
192        };
193        assert_eq!(decide(&policy, &busy), None);
194    }
195
196    /// Carried over from the uploader's periodic-push test.
197    #[test]
198    fn periodic_fires_for_never_quiescent_session() {
199        let input = TriggerInput {
200            idle_for: Some(Duration::from_secs(2)),
201            since_last_push: Duration::from_secs(301),
202            ..base_input()
203        };
204        assert_eq!(
205            decide(&TriggerPolicy::default(), &input),
206            Some(PushReason::Periodic),
207        );
208    }
209
210    /// Carried over from the uploader's exit-precedence test.
211    #[test]
212    fn exit_outranks_timers() {
213        let input = TriggerInput {
214            exited: true,
215            idle_for: Some(Duration::from_secs(0)),
216            ..base_input()
217        };
218        assert_eq!(
219            decide(&TriggerPolicy::default(), &input),
220            Some(PushReason::Exit),
221        );
222    }
223
224    /// Carried over from the uploader's backoff-gate test.
225    #[test]
226    fn backoff_gates_everything() {
227        let input = TriggerInput {
228            exited: true,
229            idle_for: Some(Duration::from_secs(3600)),
230            since_last_push: Duration::from_secs(3600),
231            in_backoff: true,
232            ..base_input()
233        };
234        assert_eq!(decide(&TriggerPolicy::default(), &input), None);
235    }
236
237    /// The thresholds are exactly inclusive: a window is satisfied *at* its
238    /// boundary, not one tick after. Worth pinning because the comparison is
239    /// `>=` and a client's tick will land on the boundary regularly.
240    #[test]
241    fn thresholds_are_inclusive_at_the_boundary() {
242        let policy = TriggerPolicy::default();
243        assert_eq!(
244            decide(
245                &policy,
246                &TriggerInput {
247                    idle_for: Some(policy.quiescence),
248                    ..base_input()
249                },
250            ),
251            Some(PushReason::Quiescence),
252        );
253        assert_eq!(
254            decide(
255                &policy,
256                &TriggerInput {
257                    idle_for: Some(policy.quiescence - Duration::from_nanos(1)),
258                    since_last_push: policy.periodic,
259                    ..base_input()
260                },
261            ),
262            Some(PushReason::Periodic),
263        );
264    }
265
266    /// A session with no transcript files yet (`idle_for: None`) cannot quiesce,
267    /// but the periodic net still reaches it — and exit still retires it. This is
268    /// the wire-attributed-before-first-flush case.
269    #[test]
270    fn a_session_with_no_files_yet_still_reaches_the_other_triggers() {
271        let policy = TriggerPolicy::default();
272        let pending = TriggerInput {
273            idle_for: None,
274            ..base_input()
275        };
276        assert_eq!(decide(&policy, &pending), None);
277        assert_eq!(
278            decide(
279                &policy,
280                &TriggerInput {
281                    since_last_push: Duration::from_secs(301),
282                    ..pending
283                },
284            ),
285            Some(PushReason::Periodic),
286        );
287        assert_eq!(
288            decide(
289                &policy,
290                &TriggerInput {
291                    exited: true,
292                    ..pending
293                },
294            ),
295            Some(PushReason::Exit),
296        );
297    }
298
299    /// The default policy is the one the extracted uploader shipped: 30 s
300    /// quiescence, 5 min periodic. Pinned so adopting the crate's default
301    /// cannot silently re-tune a consumer's push latency.
302    #[test]
303    fn default_policy_matches_the_shipped_thresholds() {
304        let policy = TriggerPolicy::default();
305        assert_eq!(policy.quiescence, Duration::from_secs(30));
306        assert_eq!(policy.periodic, Duration::from_secs(300));
307        assert_eq!(DEFAULT_TICK, Duration::from_secs(5));
308    }
309
310    #[test]
311    fn push_reason_labels_are_stable() {
312        assert_eq!(PushReason::Quiescence.as_str(), "quiescence");
313        assert_eq!(PushReason::Periodic.as_str(), "periodic");
314        assert_eq!(PushReason::Exit.as_str(), "exit");
315    }
316}