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}