flint_lean/news.rs
1//! The news poll (latency plan 2026-10-23, lever B).
2//!
3//! The bucket used to be read only inside a barrier, and a barrier runs at
4//! the floor tick (`floor_secs`, 60 s) unless the agent publishes. So a
5//! peer's or the UI's change reached an agent's tree in up to a minute,
6//! and a gateway boundary request waited as long. While the workspace is
7//! ACTIVE, this arm reads what an idle tick's first two steps read, every
8//! `news_poll_secs`: a writer's cell (the gateway's requests ride it,
9//! §2.5), then the manifest pointer. When the pointer names a document
10//! newer than the news ticker's, the ticker moves at once (D5), and the
11//! floor's barrier runs early, at most once per `news_min_interval_secs`.
12//!
13//! What it never does:
14//! - Honour a pending publish or sync. Those stay the sentinel arm's and
15//! the floor's, under the min-interval and the hourly budget: the early
16//! barrier is `cadence_floor`, never `floor_tick`. A request it finds
17//! standing is carried, as every barrier carries one (H10).
18//! - Run a barrier for this tree's own install: the ticker already names
19//! it ("our own install is the newest news", `touch_remote_seq`).
20//! - Move the floor. Its interval stays independent of this arm (D3/D12):
21//! resetting it on news would let a busy peer starve the floor's
22//! deferred honours.
23//! - Poll an idle workspace. Past `news_active_secs` without work the arm
24//! stops, and only the floor reads the bucket, as before.
25//!
26//! The model: nothing new. A poll is two reads. Folding a gateway request
27//! between barriers is the sentinel arm's `Touch`, which needs only an
28//! idle writer. The early barrier is a barrier, and the model does not
29//! time barriers.
30
31use std::time::{Duration, Instant};
32
33use super::sentinel::FloorOutcome;
34use super::{manifest, LeanConfig, LeanResult, Syncer};
35
36/// The arm's schedule: whether the workspace counts as active, whether
37/// news is waiting for a barrier, and when the last early barrier ran.
38/// Pure: every question takes `now`.
39#[derive(Debug, Clone)]
40pub struct NewsClock {
41 poll: Duration,
42 active_for: Duration,
43 min_interval: Duration,
44 active_until: Instant,
45 pending: bool,
46 last_early: Option<Instant>,
47}
48
49impl NewsClock {
50 /// A syncer starts active: an agent starts work at checkout.
51 pub fn new(cfg: &LeanConfig, now: Instant) -> NewsClock {
52 let active_for = Duration::from_secs(cfg.news_active_secs);
53 NewsClock {
54 poll: Duration::from_secs(cfg.news_poll_secs),
55 active_for,
56 min_interval: Duration::from_secs(cfg.news_min_interval_secs),
57 active_until: now + active_for,
58 pending: false,
59 last_early: None,
60 }
61 }
62
63 pub fn enabled(&self) -> bool {
64 !self.poll.is_zero()
65 }
66
67 /// The arm's interval (a disabled arm's is never polled).
68 pub fn every(&self) -> Duration {
69 self.poll.max(Duration::from_secs(1))
70 }
71
72 pub fn active(&self, now: Instant) -> bool {
73 self.enabled() && now < self.active_until
74 }
75
76 /// A sign of work: the active window runs from now.
77 pub fn touch(&mut self, now: Instant) {
78 self.active_until = self.active_until.max(now + self.active_for);
79 }
80
81 /// The pointer moved past what the tree knows.
82 pub fn saw_news(&mut self, now: Instant) {
83 self.pending = true;
84 self.touch(now);
85 }
86
87 /// A barrier ran (any arm's, after the news): its consume read the
88 /// pointer the news named, or a later one.
89 pub fn taken(&mut self) {
90 self.pending = false;
91 }
92
93 pub fn pending(&self) -> bool {
94 self.pending
95 }
96
97 /// An early barrier started now (it counts against the interval
98 /// whether or not it succeeds).
99 pub fn ran_early(&mut self, now: Instant) {
100 self.last_early = Some(now);
101 }
102
103 /// News is waiting and the last early barrier is at least the
104 /// minimum interval ago.
105 pub fn due(&self, now: Instant) -> bool {
106 self.pending
107 && self.last_early.map(|t| now.saturating_duration_since(t) >= self.min_interval).unwrap_or(true)
108 }
109}
110
111/// Did a tick do anything a workspace counts as work?
112pub fn worked(o: &FloorOutcome) -> bool {
113 !o.no_change || !o.acks.is_empty() || o.uploaded > 0 || o.deleted > 0 || o.consumed > 0
114}
115
116impl Syncer {
117 /// One poll: what an idle tick's first two steps read, nothing else.
118 /// A writer reads the cell first, so a gateway boundary request becomes
119 /// the pending publish the sentinel arm honours within its next second
120 /// (§2.5's door, a second instead of a floor). Then the pointer:
121 /// `Some(seq)` when it names a document newer than the ticker's
122 /// `observed_seq`, which moves to it now, so the agent sees the news
123 /// before a barrier takes it. `integrated_seq` stays where it was.
124 pub async fn news_poll(&mut self) -> LeanResult<Option<u64>> {
125 if !self.cfg.access.is_read() {
126 self.read_cell_requests().await?;
127 }
128 // A workspace from before the pointer layout has no seq to compare:
129 // its news waits for the floor.
130 let Some(p) = manifest::load_pointer(self.store.as_ref(), &self.cfg).await? else {
131 return Ok(None);
132 };
133 let t = self.load_remote_seq();
134 if p.pointer.seq <= t.observed_seq {
135 return Ok(None);
136 }
137 self.touch_remote_seq(Some(p.pointer.seq), Some(p.etag), t.integrated_seq)?;
138 Ok(Some(p.pointer.seq))
139 }
140
141 /// Once, before the arm's first poll: the ticker names the document the
142 /// tree holds. A checkout writes no ticker, so a fresh tree's first poll
143 /// would otherwise read its own checkout as news and run a barrier.
144 pub fn news_start(&self) -> LeanResult<()> {
145 self.ticker_from(None, None)
146 }
147
148 /// The early barrier: the floor's own, without the floor's honours. A
149 /// writer's cadence barrier, a reader's pull.
150 pub async fn news_tick(&mut self) -> LeanResult<FloorOutcome> {
151 let out = FloorOutcome::default();
152 if self.cfg.access.is_read() {
153 self.reader_floor(out).await
154 } else {
155 self.cadence_floor(out).await
156 }
157 }
158
159 /// The loop's news arm, one tick: nothing while the workspace is idle;
160 /// else a poll, and the early barrier when news is due. A failed
161 /// barrier keeps the news pending and still counts against the
162 /// interval, so a failing store is retried at that pace, not every
163 /// second.
164 pub async fn news_arm(&mut self, clock: &mut NewsClock, now: Instant) -> LeanResult<Option<FloorOutcome>> {
165 if !clock.active(now) {
166 return Ok(None);
167 }
168 if self.news_poll().await?.is_some() {
169 clock.saw_news(now);
170 }
171 if !clock.due(now) {
172 return Ok(None);
173 }
174 clock.ran_early(now);
175 let out = self.news_tick().await?;
176 clock.taken();
177 Ok(Some(out))
178 }
179}