de_mls/app/phase_timer.rs
1//! App-side phase timer.
2//!
3//! Holds the wall-clock anchor (`started_at`). Phase-anchor durations live
4//! in [`crate::core::ConversationConfig`] (single source of truth);
5//! [`crate::app::SessionRunner`] reads durations off the handle's config
6//! and passes them to this timer's elapsed-checks.
7
8use std::time::{Duration, Instant};
9
10/// What a freeze-timeout poll returned.
11#[derive(Debug, PartialEq)]
12pub enum FreezeTimeoutStatus {
13 NotFreezing,
14 StillFreezing,
15 /// A candidate was selected and applied.
16 Applied,
17 /// Timeout elapsed without a valid candidate. `has_proposals = true`
18 /// means approved work existed at timeout (steward fault); `false` is
19 /// just an empty epoch.
20 TimedOut {
21 has_proposals: bool,
22 },
23}
24
25/// Wall-clock anchor for the active phase. Holds only the anchor
26/// `Instant`; queries take the relevant `Duration` as a parameter.
27/// [`crate::app::SessionRunner`] composes the timer with the state
28/// machine and [`crate::core::ConversationConfig`] durations.
29#[derive(Debug, Clone, Default)]
30pub struct PhaseTimer {
31 /// Meaning depends on the orchestrator's intent at start time:
32 /// - PendingJoin: time the join was initiated.
33 /// - Working: time the first approved proposal arrived
34 /// (drives the steward-inactivity timer).
35 /// - Freezing: time the freeze window started.
36 /// - Other states: `None`.
37 started_at: Option<Instant>,
38}
39
40impl PhaseTimer {
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 /// Anchor the timer at "now". Called by the orchestrator when entering
46 /// a phase whose timeout matters (PendingJoin, Freezing, on first
47 /// approved proposal in Working).
48 pub fn start(&mut self) {
49 self.started_at = Some(Instant::now());
50 }
51
52 /// Drop the anchor. Called by the orchestrator when leaving a
53 /// time-bounded phase.
54 pub fn clear(&mut self) {
55 self.started_at = None;
56 }
57
58 pub fn started_at(&self) -> Option<Instant> {
59 self.started_at
60 }
61
62 /// `false` when no anchor is set. Caller is responsible for state
63 /// guarding and for choosing the right duration for the current phase.
64 pub fn elapsed_since_anchor(&self, duration: Duration) -> bool {
65 match self.started_at {
66 Some(t) => Instant::now() >= t + duration,
67 None => false,
68 }
69 }
70
71 /// Test-only: overwrite the anchor with an explicit `Instant`. Lets
72 /// timer-boundary tests synthesize an aged anchor without sleeping.
73 #[cfg(test)]
74 pub(crate) fn set_started_at_for_test(&mut self, anchor: Option<Instant>) {
75 self.started_at = anchor;
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use std::time::Duration;
83
84 #[test]
85 fn unset_never_elapsed() {
86 let pt = PhaseTimer::new();
87 assert!(!pt.elapsed_since_anchor(Duration::from_secs(1)));
88 }
89
90 #[test]
91 fn fresh_anchor_not_elapsed() {
92 let mut pt = PhaseTimer::new();
93 pt.start();
94 assert!(!pt.elapsed_since_anchor(Duration::from_secs(60)));
95 }
96
97 #[test]
98 fn elapsed_when_anchor_old_enough() {
99 let mut pt = PhaseTimer::new();
100 pt.started_at = Some(Instant::now() - Duration::from_secs(30));
101 assert!(pt.elapsed_since_anchor(Duration::from_secs(1)));
102 }
103
104 #[test]
105 fn clear_drops_anchor() {
106 let mut pt = PhaseTimer::new();
107 pt.start();
108 assert!(pt.started_at().is_some());
109 pt.clear();
110 assert!(pt.started_at().is_none());
111 }
112}