Skip to main content

magi/
blockers.rs

1//! Dependency inventory for `blocked` tasks.
2//!
3//! [`crate::daemon::resolve_blockers`] frees a task when its dependency is
4//! `done` (or its question answered) and quarantines it when the dependency no
5//! longer exists. It has nothing to say about a dependency that is alive but
6//! *stuck*: a `held` task never becomes `done` on its own, so everything
7//! waiting on it waits forever, and nothing shows why. [`Inventory`] answers
8//! that from one snapshot of the queue and the questions:
9//!
10//! - [`Inventory::waits_on`] - what a blocked task waits on and each
11//!   dependency's state, following a chain of blocked dependencies
12//!   (`4135 (blocked → 9db7 held)`), for `magi task list` and the web UI.
13//! - [`Inventory::stuck_roots`] - the tasks nothing in the loop will ever run
14//!   that a blocked task is frozen behind. [`crate::triage`] asks about each
15//!   such root once, naming everything it freezes.
16//!
17//! A blocked task is *stuck* when it has at least one unresolved dependency
18//! and every one of them is either `held` or itself a stuck blocked task.
19//! Anything that can still make progress - a queued, running or failed
20//! (retried) task, an open question, a dependency that no longer exists (which
21//! `resolve_blockers` quarantines) - makes it not stuck. A dependency cycle
22//! terminates: its members are stuck, and the smallest id in the cycle is its
23//! root.
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use crate::ask::{Question, QuestionStatus};
28use crate::queue::{Task, TaskStatus};
29
30/// How many hops [`Inventory::waits_on`] follows before it stops describing.
31const CHAIN_DEPTH: usize = 4;
32
33/// One snapshot of the tasks and questions a `blocked_by` id can name.
34#[derive(Debug, Clone, Default)]
35pub struct Inventory {
36    tasks: BTreeMap<String, Task>,
37    questions: BTreeMap<String, QuestionStatus>,
38    ids: Vec<String>,
39    complete: bool,
40}
41
42enum Dep<'a> {
43    Task(&'a Task),
44    Question(QuestionStatus),
45    Missing,
46    Unknown,
47}
48
49type Roots = Option<BTreeSet<String>>;
50
51/// The last dash-separated part of an id: what `Task::short` shows.
52fn short(id: &str) -> &str {
53    id.split('-').next_back().unwrap_or(id)
54}
55
56impl Inventory {
57    /// Build the snapshot. Callers take `queue.list()` and `questions.list()`
58    /// once, so a listing over many tasks never rescans the disk per task.
59    pub fn new(tasks: Vec<Task>, questions: &[Question]) -> Self {
60        let ids = tasks
61            .iter()
62            .map(|t| t.id.clone())
63            .chain(questions.iter().map(|q| q.id.clone()))
64            .collect();
65        Self {
66            ids,
67            complete: true,
68            tasks: tasks.into_iter().map(|t| (t.id.clone(), t)).collect(),
69            questions: questions.iter().map(|q| (q.id.clone(), q.status)).collect(),
70        }
71    }
72
73    /// Include unreadable records and tombstones in id resolution; list()
74    /// alone cannot prove uniqueness or absence.
75    pub fn load(queue: &crate::queue::Queue, questions: &crate::ask::Questions) -> Self {
76        let mut inventory = Self::new(queue.list(), &questions.list());
77        let ids = (|| -> anyhow::Result<Vec<String>> {
78            let mut ids = crate::queue::record_ids(queue.root(), "json")?;
79            ids.extend(crate::queue::record_ids(questions.root(), "json")?);
80            ids.extend(crate::queue::record_ids(queue.root(), "removed")?);
81            ids.sort();
82            ids.dedup();
83            Ok(ids)
84        })();
85        match ids {
86            Ok(ids) => inventory.ids = ids,
87            Err(_) => inventory.complete = false,
88        }
89        inventory
90    }
91
92    fn dep(&self, id: &str) -> Dep<'_> {
93        // A partial scan does not invalidate readable exact records. It does
94        // prevent proving that a prefix/suffix is unique across both stores.
95        if let Some(t) = self.tasks.get(id) {
96            return Dep::Task(t);
97        }
98        if let Some(s) = self.questions.get(id) {
99            return Dep::Question(*s);
100        }
101        if !self.complete {
102            return Dep::Unknown;
103        }
104        match crate::queue::resolve_reference(id, self.ids.iter().map(String::as_str)) {
105            crate::queue::Reference::Found(full) => {
106                if let Some(t) = self.tasks.get(&full) {
107                    Dep::Task(t)
108                } else if let Some(s) = self.questions.get(&full) {
109                    Dep::Question(*s)
110                } else {
111                    Dep::Unknown
112                }
113            }
114            crate::queue::Reference::Missing => Dep::Missing,
115            crate::queue::Reference::Ambiguous => Dep::Unknown,
116        }
117    }
118
119    /// A dependency that has nothing left to wait for.
120    fn resolved(&self, id: &str) -> bool {
121        match self.dep(id) {
122            Dep::Task(t) => t.status == TaskStatus::Done,
123            Dep::Question(s) => s == QuestionStatus::Answered,
124            Dep::Missing | Dep::Unknown => false,
125        }
126    }
127
128    fn walk(
129        &self,
130        id: &str,
131        path: &mut Vec<String>,
132        memo: &mut BTreeMap<String, Roots>,
133    ) -> (Roots, bool) {
134        if let Some(v) = memo.get(id) {
135            return (v.clone(), false);
136        }
137        let Some(task) = self.tasks.get(id) else {
138            return (None, false);
139        };
140        path.push(id.to_owned());
141        let mut roots = BTreeSet::new();
142        let mut moving = false;
143        let mut cyclic = false;
144        for b in &task.blocked_by {
145            match self.dep(b) {
146                Dep::Task(t) => match t.status {
147                    TaskStatus::Done => {}
148                    TaskStatus::Held => {
149                        roots.insert(t.id.clone());
150                    }
151                    TaskStatus::Blocked => {
152                        if let Some(pos) = path.iter().position(|p| p == &t.id) {
153                            // A cycle: its smallest id speaks for all of it, so
154                            // every entry point names the same root.
155                            if let Some(min) = path[pos..].iter().min() {
156                                roots.insert(min.clone());
157                            }
158                            cyclic = true;
159                        } else {
160                            let (sub, c) = self.walk(&t.id, path, memo);
161                            cyclic |= c;
162                            match sub {
163                                Some(r) => roots.extend(r),
164                                None => moving = true,
165                            }
166                        }
167                    }
168                    TaskStatus::Queued
169                    | TaskStatus::Running
170                    | TaskStatus::Failed
171                    | TaskStatus::Parked => {
172                        moving = true;
173                    }
174                },
175                Dep::Question(QuestionStatus::Answered) => {}
176                Dep::Question(_) | Dep::Missing | Dep::Unknown => moving = true,
177            }
178        }
179        path.pop();
180        let verdict = (!moving && !roots.is_empty()).then_some(roots);
181        if !cyclic {
182            memo.insert(id.to_owned(), verdict.clone());
183        }
184        (verdict, cyclic)
185    }
186
187    /// Every stuck `blocked` task, with the root ids it is frozen behind.
188    pub fn stuck(&self) -> BTreeMap<String, BTreeSet<String>> {
189        let mut memo = BTreeMap::new();
190        let mut out = BTreeMap::new();
191        for (id, t) in &self.tasks {
192            if t.status != TaskStatus::Blocked {
193                continue;
194            }
195            let (verdict, _) = self.walk(id, &mut Vec::new(), &mut memo);
196            if let Some(roots) = verdict {
197                out.insert(id.clone(), roots);
198            }
199        }
200        out
201    }
202
203    /// The roots `task` is frozen behind; empty when it is not stuck.
204    pub fn stuck_roots(&self, task: &Task) -> BTreeSet<String> {
205        if task.status != TaskStatus::Blocked {
206            return BTreeSet::new();
207        }
208        self.walk(&task.id, &mut Vec::new(), &mut BTreeMap::new())
209            .0
210            .unwrap_or_default()
211    }
212
213    /// Each dependency of `task` with its state, e.g. `9db7 (held)` or
214    /// `4135 (blocked → 9db7 held)`. Empty unless `task` is `blocked`.
215    pub fn waits_on(&self, task: &Task) -> Vec<String> {
216        if task.status != TaskStatus::Blocked {
217            return Vec::new();
218        }
219        task.blocked_by
220            .iter()
221            .map(|b| {
222                let chain = self.chain(b, &mut vec![task.id.clone()]);
223                format!("{} ({chain})", short(b))
224            })
225            .collect()
226    }
227
228    fn chain(&self, id: &str, seen: &mut Vec<String>) -> String {
229        match self.dep(id) {
230            Dep::Missing => "missing".to_owned(),
231            Dep::Unknown => "unavailable or ambiguous".to_owned(),
232            Dep::Question(s) => format!("question {}", s.as_str()),
233            Dep::Task(t) => {
234                let mut out = t.status.as_str().to_owned();
235                if t.status != TaskStatus::Blocked {
236                    return out;
237                }
238                if seen.iter().any(|s| s == &t.id) {
239                    return format!("{out}, cycle");
240                }
241                if seen.len() > CHAIN_DEPTH {
242                    return format!("{out} → …");
243                }
244                seen.push(t.id.clone());
245                if let Some(next) = t.blocked_by.iter().find(|b| !self.resolved(b)) {
246                    out.push_str(&format!(" → {} {}", short(next), self.chain(next, seen)));
247                }
248                out
249            }
250        }
251    }
252
253    /// The task with this id, if it is one.
254    pub fn task(&self, id: &str) -> Option<&Task> {
255        match self.dep(id) {
256            Dep::Task(t) => Some(t),
257            _ => None,
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::queue::Source;
266    use std::path::PathBuf;
267
268    fn t(id: &str, status: TaskStatus, blocked_by: &[&str]) -> Task {
269        let mut t = Task::new(
270            id.to_owned(),
271            "x".to_owned(),
272            PathBuf::from("/repo"),
273            Source::Human,
274        );
275        t.id = format!("2026-{id}");
276        t.status = status;
277        t.blocked_by = blocked_by.iter().map(|b| format!("2026-{b}")).collect();
278        t
279    }
280
281    fn inv(tasks: Vec<Task>) -> Inventory {
282        Inventory::new(tasks, &[])
283    }
284
285    #[test]
286    fn dependency_resolution_partial_scan_keeps_readable_exact_records() {
287        let dir = tempfile::tempdir().unwrap();
288        let queue = crate::queue::Queue::at(dir.path().join("queue"));
289        let questions = crate::ask::Questions::at(dir.path().join("questions"));
290        let mut running = t("feb2", TaskStatus::Running, &[]);
291        let mut middle = t("ca5b", TaskStatus::Blocked, &["feb2"]);
292        let mut waiting = t("ee37", TaskStatus::Blocked, &["ca5b"]);
293        let mut held = t("held", TaskStatus::Held, &[]);
294        let mut short_wait = t("short", TaskStatus::Blocked, &[]);
295        short_wait.blocked_by = vec!["held".into()];
296        for task in [
297            &mut running,
298            &mut middle,
299            &mut waiting,
300            &mut held,
301            &mut short_wait,
302        ] {
303            queue.put(task).unwrap();
304        }
305        // Reading the queue succeeds, while scanning the question store fails.
306        std::fs::write(questions.root(), "not a directory").unwrap();
307        let inventory = Inventory::load(&queue, &questions);
308        assert!(!inventory.complete);
309        assert!(inventory.ids.contains(&running.id));
310        assert_eq!(
311            inventory.task(&running.id).unwrap().status,
312            TaskStatus::Running
313        );
314        assert_eq!(
315            inventory.waits_on(&waiting),
316            ["ca5b (blocked → feb2 running)"]
317        );
318        assert_eq!(
319            inventory.waits_on(&short_wait),
320            ["held (unavailable or ambiguous)"]
321        );
322        assert!(
323            inventory.stuck().is_empty(),
324            "partial scans cannot prove short-id uniqueness"
325        );
326        assert!(matches!(inventory.dep("not-seen"), Dep::Unknown));
327    }
328
329    #[test]
330    fn a_chain_reads_at_a_glance_and_its_root_is_the_held_task() {
331        let i = inv(vec![
332            t("9db7", TaskStatus::Held, &[]),
333            t("4135", TaskStatus::Blocked, &["9db7"]),
334            t("6081", TaskStatus::Blocked, &["4135"]),
335        ]);
336        let six = i.task("2026-6081").unwrap();
337        assert_eq!(i.waits_on(six), ["4135 (blocked → 9db7 held)"]);
338        let four = i.task("2026-4135").unwrap();
339        assert_eq!(i.waits_on(four), ["9db7 (held)"]);
340        let stuck = i.stuck();
341        assert_eq!(stuck.len(), 2);
342        assert!(stuck.values().all(|r| r.iter().eq(["2026-9db7"].iter())));
343    }
344
345    #[test]
346    fn a_dependency_that_can_still_run_is_not_stuck() {
347        let i = inv(vec![
348            t("aaaa", TaskStatus::Queued, &[]),
349            t("bbbb", TaskStatus::Held, &[]),
350            t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
351            t("dddd", TaskStatus::Blocked, &["missing1"]),
352            t("eeee", TaskStatus::Blocked, &["cccc"]),
353        ]);
354        assert!(i.stuck().is_empty(), "{:?}", i.stuck());
355    }
356
357    #[test]
358    fn a_done_dependency_does_not_hide_a_held_one() {
359        let i = inv(vec![
360            t("aaaa", TaskStatus::Done, &[]),
361            t("bbbb", TaskStatus::Held, &[]),
362            t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
363        ]);
364        assert_eq!(i.stuck().len(), 1);
365    }
366
367    #[test]
368    fn a_cycle_terminates_and_names_its_smallest_id_from_every_entry() {
369        let i = inv(vec![
370            t("bbbb", TaskStatus::Blocked, &["cccc"]),
371            t("cccc", TaskStatus::Blocked, &["aaaa"]),
372            t("aaaa", TaskStatus::Blocked, &["bbbb"]),
373            t("dddd", TaskStatus::Blocked, &["cccc"]),
374        ]);
375        let stuck = i.stuck();
376        assert_eq!(stuck.len(), 4);
377        for roots in stuck.values() {
378            assert!(roots.iter().eq(["2026-aaaa"].iter()), "{roots:?}");
379        }
380        let d = i.task("2026-dddd").unwrap();
381        assert!(i.waits_on(d)[0].contains("cycle"), "{:?}", i.waits_on(d));
382    }
383
384    #[test]
385    fn a_self_dependency_is_a_stuck_cycle_of_one() {
386        let i = inv(vec![t("aaaa", TaskStatus::Blocked, &["aaaa"])]);
387        assert_eq!(i.stuck().len(), 1);
388    }
389
390    #[test]
391    fn an_open_question_keeps_a_task_alive_and_an_answered_one_resolves() {
392        let mut open = Question::new(
393            "r".into(),
394            "n".into(),
395            "s".into(),
396            "?".into(),
397            String::new(),
398            vec![],
399        );
400        open.id = "2026-qqqq".into();
401        let mut answered = open.clone();
402        answered.id = "2026-rrrr".into();
403        answered.status = QuestionStatus::Answered;
404        let tasks = vec![
405            t("bbbb", TaskStatus::Held, &[]),
406            t("cccc", TaskStatus::Blocked, &["bbbb", "qqqq"]),
407            t("dddd", TaskStatus::Blocked, &["bbbb", "rrrr"]),
408        ];
409        let i = Inventory::new(tasks, &[open, answered]);
410        let stuck = i.stuck();
411        assert!(!stuck.contains_key("2026-cccc"));
412        assert!(stuck.contains_key("2026-dddd"));
413        let c = i.task("2026-cccc").unwrap();
414        assert_eq!(i.waits_on(c)[1], "qqqq (question open)");
415    }
416}