Skip to main content

mecha_core/
backlog.rs

1//! What is waiting on the owner, across every store that accumulates.
2//!
3//! Five stores collect work for a person and each grew its own verb, which is
4//! how the knowledge graph's merge queue reached 6,434 items without anybody
5//! deciding to let it. `mecha review` answers "what is waiting" for a human;
6//! `doctor` answers "what is silently wrong"; and the goal system needs a
7//! third answer — *how much does this run owe the owner, and did it just make
8//! that worse* (`docs/GOAL-SYSTEM-DESIGN.md` §4).
9//!
10//! Three questions, one walk. This module is the walk. It computes nothing
11//! about health and renders nothing for a screen: it counts what is waiting
12//! and how long the oldest has waited, and every reader on top decides what
13//! that means. Same division `runlog` keeps — **the module counts and never
14//! judges**, because what counts as too much depends on who is asking.
15//!
16//! ## Two absences that are not the same, and never collapse
17//!
18//! - **`None` means the store could not be read.** Not "nothing is waiting".
19//!   Those are opposite findings, and a reader that renders the second as the
20//!   first reproduces exactly the bug the unified queue exists to catch.
21//! - **A store that does not exist yet is genuinely empty** — `Some(0)`, not
22//!   `None`. A machine that has never delegated a task has no question store,
23//!   and reporting that as unreadable would make it indistinguishable from one
24//!   whose store is broken.
25//!
26//! And a partial read stays partial. [`Backlog::waiting`] returns the sum of
27//! what it could read *beside* the number of stores it could not, rather than
28//! choosing between understating the total and discarding it. A caller that
29//! needs to know whether the number is complete can see that it is not.
30//!
31//! **The graph's queues are deliberately absent.** Reaching them needs a
32//! `mecha-graph` subprocess, which is fine once a night and far too expensive
33//! in the path of every run. `mecha review` adds them on top for its own view.
34
35use crate::frontdoor::{self, Frontdoor};
36use crate::harness::HarnessStore;
37use crate::learning::LearningStore;
38use crate::outbox::OutboxStore;
39use crate::questions::QuestionStore;
40use serde::{Deserialize, Serialize};
41
42/// One store's contribution: how much waits, and since when.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Depth {
45    pub waiting: usize,
46    /// RFC3339 stamp of the oldest still-waiting item. `None` when nothing is
47    /// waiting — an absent age, never a zero one.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub oldest: Option<String>,
50}
51
52impl Depth {
53    fn of<'a>(waiting: usize, stamps: impl IntoIterator<Item = &'a str>) -> Depth {
54        Depth {
55            waiting,
56            oldest: stamps.into_iter().min().map(str::to_string),
57        }
58    }
59}
60
61/// Everything waiting on the owner in mecha's own stores.
62///
63/// Each field is `None` when that store could not be read.
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Backlog {
66    pub outbox: Option<Depth>,
67    pub questions: Option<Depth>,
68    pub frontdoor: Option<Depth>,
69    pub proposals: Option<Depth>,
70    pub candidates: Option<Depth>,
71}
72
73/// A total, and how much of it is missing.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub struct Waiting {
76    /// Summed over the stores that could be read.
77    pub total: usize,
78    /// How many stores could not be, so a caller knows the total is partial.
79    pub unreadable: usize,
80}
81
82impl Backlog {
83    /// Read every mecha-owned store. Best-effort per store, like doctor: one
84    /// unreadable store never suppresses the other four.
85    pub fn read() -> Backlog {
86        Backlog {
87            outbox: Self::read_outbox(),
88            questions: Self::read_questions(),
89            frontdoor: Self::read_frontdoor(),
90            proposals: Self::read_proposals(),
91            candidates: Self::read_candidates(),
92        }
93    }
94
95    fn read_outbox() -> Option<Depth> {
96        let store = OutboxStore::default_root()
97            .and_then(OutboxStore::open)
98            .ok()?;
99        let items = store.items().ok()?;
100        let pending: Vec<_> = items.iter().filter(|i| i.status == "pending").collect();
101        Some(Depth::of(
102            pending.len(),
103            pending.iter().map(|i| i.created_at.as_str()),
104        ))
105    }
106
107    fn read_questions() -> Option<Depth> {
108        // A store that has never existed is empty, not unreadable.
109        let Some(store) = QuestionStore::open_existing_default() else {
110            return Some(Depth::default());
111        };
112        let items = store.items().ok()?;
113        let open: Vec<_> = items.iter().filter(|q| q.is_open()).collect();
114        Some(Depth::of(
115            open.len(),
116            open.iter().map(|q| q.asked_at.as_str()),
117        ))
118    }
119
120    fn read_frontdoor() -> Option<Depth> {
121        let records = Frontdoor::open_default().and_then(|s| s.records()).ok()?;
122        let open: Vec<_> = records
123            .iter()
124            .filter(|r| r.state != frontdoor::CLOSED)
125            .collect();
126        Some(Depth::of(
127            open.len(),
128            open.iter().map(|r| r.created_at.as_str()),
129        ))
130    }
131
132    fn read_proposals() -> Option<Depth> {
133        let store = LearningStore::default_root()
134            .and_then(LearningStore::open)
135            .ok()?;
136        let proposals = store.proposals().ok()?;
137        let pending: Vec<_> = proposals.iter().filter(|p| p.status == "pending").collect();
138        Some(Depth::of(
139            pending.len(),
140            pending.iter().map(|p| p.created_at.as_str()),
141        ))
142    }
143
144    fn read_candidates() -> Option<Depth> {
145        let candidates = HarnessStore::open_default().and_then(|s| s.all()).ok()?;
146        let staged: Vec<_> = candidates.iter().filter(|c| c.pending()).collect();
147        Some(Depth::of(
148            staged.len(),
149            staged.iter().map(|c| c.created_at.as_str()),
150        ))
151    }
152
153    fn depths(&self) -> [&Option<Depth>; 5] {
154        [
155            &self.outbox,
156            &self.questions,
157            &self.frontdoor,
158            &self.proposals,
159            &self.candidates,
160        ]
161    }
162
163    /// How much is waiting, and over how many stores that could not be read.
164    pub fn waiting(&self) -> Waiting {
165        let mut out = Waiting::default();
166        for depth in self.depths() {
167            match depth {
168                Some(d) => out.total += d.waiting,
169                None => out.unreadable += 1,
170            }
171        }
172        out
173    }
174
175    /// The oldest still-waiting stamp anywhere, RFC3339.
176    ///
177    /// The signal behind *"never leave a person waiting"*: a queue of one item
178    /// nine days old is a different failure from nine items an hour old, and a
179    /// count alone cannot tell them apart.
180    pub fn oldest(&self) -> Option<&str> {
181        self.depths()
182            .into_iter()
183            .flatten()
184            .filter_map(|d| d.oldest.as_deref())
185            .min()
186    }
187
188    /// What this run changed, per store.
189    ///
190    /// `None` for a store unreadable at either end — a delta against an
191    /// unknown is not zero.
192    pub fn delta(before: &Backlog, after: &Backlog) -> BacklogDelta {
193        let d = |a: &Option<Depth>, b: &Option<Depth>| match (a, b) {
194            (Some(a), Some(b)) => Some(b.waiting as i64 - a.waiting as i64),
195            _ => None,
196        };
197        BacklogDelta {
198            outbox: d(&before.outbox, &after.outbox),
199            questions: d(&before.questions, &after.questions),
200            frontdoor: d(&before.frontdoor, &after.frontdoor),
201            proposals: d(&before.proposals, &after.proposals),
202            candidates: d(&before.candidates, &after.candidates),
203        }
204    }
205}
206
207/// What one run added to, or took off, the owner's plate.
208///
209/// **The appraisal-relevant quantity, and the reason a level alone will not
210/// do.** A run that stages nine drafts raises the outbox's depth by nine; read
211/// as a level at run end, its own output is indistinguishable from a backlog
212/// it inherited. The question the goal system asks — *did this run leave the
213/// owner better or worse off* — is answerable only from the difference.
214#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
215pub struct BacklogDelta {
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub outbox: Option<i64>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub questions: Option<i64>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub frontdoor: Option<i64>,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub proposals: Option<i64>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub candidates: Option<i64>,
226}
227
228impl BacklogDelta {
229    /// Net change across the stores that could be read at both ends.
230    ///
231    /// `None` when none could — not zero, which would read as "this run
232    /// changed nothing" when the truth is that nobody looked.
233    pub fn net(&self) -> Option<i64> {
234        let seen: Vec<i64> = [
235            self.outbox,
236            self.questions,
237            self.frontdoor,
238            self.proposals,
239            self.candidates,
240        ]
241        .into_iter()
242        .flatten()
243        .collect();
244        (!seen.is_empty()).then(|| seen.iter().sum())
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn depth(waiting: usize, oldest: Option<&str>) -> Option<Depth> {
253        Some(Depth {
254            waiting,
255            oldest: oldest.map(str::to_string),
256        })
257    }
258
259    /// The bug the unified queue exists to catch, one layer down. A reader
260    /// that folded an unreadable store into the total would report a broken
261    /// store as a quiet one.
262    #[test]
263    fn an_unreadable_store_is_counted_as_unread_and_never_as_empty() {
264        let b = Backlog {
265            outbox: depth(3, Some("2026-08-20T09:00:00Z")),
266            questions: None, // could not read
267            frontdoor: depth(0, None),
268            proposals: depth(1, Some("2026-08-25T09:00:00Z")),
269            candidates: None, // could not read
270        };
271        assert_eq!(
272            b.waiting(),
273            Waiting {
274                total: 4,
275                unreadable: 2
276            },
277            "the total is what was readable, and says so"
278        );
279    }
280
281    #[test]
282    fn a_store_with_nothing_waiting_has_no_oldest_age() {
283        let empty = Depth::of(0, Vec::<&str>::new());
284        assert_eq!(empty.waiting, 0);
285        assert_eq!(empty.oldest, None, "an absent age, never a zero one");
286    }
287
288    /// A count cannot tell one nine-day-old request from nine one-hour-old
289    /// ones, and only the first is the failure the charter cares about.
290    #[test]
291    fn the_oldest_wait_is_the_earliest_stamp_across_every_store() {
292        let b = Backlog {
293            outbox: depth(2, Some("2026-08-25T09:00:00Z")),
294            questions: depth(1, Some("2026-08-17T09:00:00Z")),
295            frontdoor: depth(0, None),
296            proposals: None,
297            candidates: depth(1, Some("2026-08-26T09:00:00Z")),
298        };
299        assert_eq!(b.oldest(), Some("2026-08-17T09:00:00Z"));
300        assert_eq!(Backlog::default().oldest(), None);
301    }
302
303    /// The appraisal-relevant quantity. A run that stages nine drafts raises
304    /// the outbox by nine; a level at run end cannot separate that from a
305    /// backlog it inherited.
306    #[test]
307    fn a_delta_reports_what_this_run_added_rather_than_what_it_found() {
308        let before = Backlog {
309            outbox: depth(2, None),
310            questions: depth(1, None),
311            frontdoor: depth(4, None),
312            proposals: None,
313            candidates: depth(0, None),
314        };
315        let after = Backlog {
316            outbox: depth(11, None),   // the run staged nine
317            questions: depth(0, None), // and one got answered
318            frontdoor: depth(4, None),
319            proposals: depth(2, None), // unreadable before, so unknown
320            candidates: None,          // unreadable after, so unknown
321        };
322        let d = Backlog::delta(&before, &after);
323        assert_eq!(d.outbox, Some(9));
324        assert_eq!(d.questions, Some(-1));
325        assert_eq!(d.frontdoor, Some(0), "readable and genuinely unchanged");
326        assert_eq!(d.proposals, None, "a delta against an unknown is not zero");
327        assert_eq!(d.candidates, None);
328        assert_eq!(d.net(), Some(8));
329    }
330
331    /// "This run changed nothing" and "nobody could look" are opposite
332    /// findings, and the second must not render as the first.
333    #[test]
334    fn a_net_over_nothing_readable_is_absent_rather_than_zero() {
335        assert_eq!(BacklogDelta::default().net(), None);
336        assert_eq!(
337            BacklogDelta {
338                outbox: Some(0),
339                ..BacklogDelta::default()
340            }
341            .net(),
342            Some(0),
343            "a real zero is a different answer and stays one"
344        );
345    }
346}