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}
39
40enum Dep<'a> {
41    Task(&'a Task),
42    Question(QuestionStatus),
43    Missing,
44}
45
46type Roots = Option<BTreeSet<String>>;
47
48/// The last dash-separated part of an id: what `Task::short` shows.
49fn short(id: &str) -> &str {
50    id.split('-').next_back().unwrap_or(id)
51}
52
53impl Inventory {
54    /// Build the snapshot. Callers take `queue.list()` and `questions.list()`
55    /// once, so a listing over many tasks never rescans the disk per task.
56    pub fn new(tasks: Vec<Task>, questions: &[Question]) -> Self {
57        Self {
58            tasks: tasks.into_iter().map(|t| (t.id.clone(), t)).collect(),
59            questions: questions.iter().map(|q| (q.id.clone(), q.status)).collect(),
60        }
61    }
62
63    fn dep(&self, id: &str) -> Dep<'_> {
64        if let Some(t) = self.tasks.get(id) {
65            Dep::Task(t)
66        } else if let Some(s) = self.questions.get(id) {
67            Dep::Question(*s)
68        } else {
69            Dep::Missing
70        }
71    }
72
73    /// A dependency that has nothing left to wait for.
74    fn resolved(&self, id: &str) -> bool {
75        match self.dep(id) {
76            Dep::Task(t) => t.status == TaskStatus::Done,
77            Dep::Question(s) => s == QuestionStatus::Answered,
78            Dep::Missing => false,
79        }
80    }
81
82    fn walk(
83        &self,
84        id: &str,
85        path: &mut Vec<String>,
86        memo: &mut BTreeMap<String, Roots>,
87    ) -> (Roots, bool) {
88        if let Some(v) = memo.get(id) {
89            return (v.clone(), false);
90        }
91        let Some(task) = self.tasks.get(id) else {
92            return (None, false);
93        };
94        path.push(id.to_owned());
95        let mut roots = BTreeSet::new();
96        let mut moving = false;
97        let mut cyclic = false;
98        for b in &task.blocked_by {
99            match self.dep(b) {
100                Dep::Task(t) => match t.status {
101                    TaskStatus::Done => {}
102                    TaskStatus::Held => {
103                        roots.insert(b.clone());
104                    }
105                    TaskStatus::Blocked => {
106                        if let Some(pos) = path.iter().position(|p| p == b) {
107                            // A cycle: its smallest id speaks for all of it, so
108                            // every entry point names the same root.
109                            if let Some(min) = path[pos..].iter().min() {
110                                roots.insert(min.clone());
111                            }
112                            cyclic = true;
113                        } else {
114                            let (sub, c) = self.walk(b, path, memo);
115                            cyclic |= c;
116                            match sub {
117                                Some(r) => roots.extend(r),
118                                None => moving = true,
119                            }
120                        }
121                    }
122                    TaskStatus::Queued
123                    | TaskStatus::Running
124                    | TaskStatus::Failed
125                    | TaskStatus::Parked => {
126                        moving = true;
127                    }
128                },
129                Dep::Question(QuestionStatus::Answered) => {}
130                Dep::Question(_) | Dep::Missing => moving = true,
131            }
132        }
133        path.pop();
134        let verdict = (!moving && !roots.is_empty()).then_some(roots);
135        if !cyclic {
136            memo.insert(id.to_owned(), verdict.clone());
137        }
138        (verdict, cyclic)
139    }
140
141    /// Every stuck `blocked` task, with the root ids it is frozen behind.
142    pub fn stuck(&self) -> BTreeMap<String, BTreeSet<String>> {
143        let mut memo = BTreeMap::new();
144        let mut out = BTreeMap::new();
145        for (id, t) in &self.tasks {
146            if t.status != TaskStatus::Blocked {
147                continue;
148            }
149            let (verdict, _) = self.walk(id, &mut Vec::new(), &mut memo);
150            if let Some(roots) = verdict {
151                out.insert(id.clone(), roots);
152            }
153        }
154        out
155    }
156
157    /// The roots `task` is frozen behind; empty when it is not stuck.
158    pub fn stuck_roots(&self, task: &Task) -> BTreeSet<String> {
159        if task.status != TaskStatus::Blocked {
160            return BTreeSet::new();
161        }
162        self.walk(&task.id, &mut Vec::new(), &mut BTreeMap::new())
163            .0
164            .unwrap_or_default()
165    }
166
167    /// Each dependency of `task` with its state, e.g. `9db7 (held)` or
168    /// `4135 (blocked → 9db7 held)`. Empty unless `task` is `blocked`.
169    pub fn waits_on(&self, task: &Task) -> Vec<String> {
170        if task.status != TaskStatus::Blocked {
171            return Vec::new();
172        }
173        task.blocked_by
174            .iter()
175            .map(|b| {
176                let chain = self.chain(b, &mut vec![task.id.clone()]);
177                format!("{} ({chain})", short(b))
178            })
179            .collect()
180    }
181
182    fn chain(&self, id: &str, seen: &mut Vec<String>) -> String {
183        match self.dep(id) {
184            Dep::Missing => "missing".to_owned(),
185            Dep::Question(s) => format!("question {}", s.as_str()),
186            Dep::Task(t) => {
187                let mut out = t.status.as_str().to_owned();
188                if t.status != TaskStatus::Blocked {
189                    return out;
190                }
191                if seen.iter().any(|s| s == id) {
192                    return format!("{out}, cycle");
193                }
194                if seen.len() > CHAIN_DEPTH {
195                    return format!("{out} → …");
196                }
197                seen.push(id.to_owned());
198                if let Some(next) = t.blocked_by.iter().find(|b| !self.resolved(b)) {
199                    out.push_str(&format!(" → {} {}", short(next), self.chain(next, seen)));
200                }
201                out
202            }
203        }
204    }
205
206    /// The task with this id, if it is one.
207    pub fn task(&self, id: &str) -> Option<&Task> {
208        self.tasks.get(id)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::queue::Source;
216    use std::path::PathBuf;
217
218    fn t(id: &str, status: TaskStatus, blocked_by: &[&str]) -> Task {
219        let mut t = Task::new(
220            id.to_owned(),
221            "x".to_owned(),
222            PathBuf::from("/repo"),
223            Source::Human,
224        );
225        t.id = format!("2026-{id}");
226        t.status = status;
227        t.blocked_by = blocked_by.iter().map(|b| format!("2026-{b}")).collect();
228        t
229    }
230
231    fn inv(tasks: Vec<Task>) -> Inventory {
232        Inventory::new(tasks, &[])
233    }
234
235    #[test]
236    fn a_chain_reads_at_a_glance_and_its_root_is_the_held_task() {
237        let i = inv(vec![
238            t("9db7", TaskStatus::Held, &[]),
239            t("4135", TaskStatus::Blocked, &["9db7"]),
240            t("6081", TaskStatus::Blocked, &["4135"]),
241        ]);
242        let six = i.task("2026-6081").unwrap();
243        assert_eq!(i.waits_on(six), ["4135 (blocked → 9db7 held)"]);
244        let four = i.task("2026-4135").unwrap();
245        assert_eq!(i.waits_on(four), ["9db7 (held)"]);
246        let stuck = i.stuck();
247        assert_eq!(stuck.len(), 2);
248        assert!(stuck.values().all(|r| r.iter().eq(["2026-9db7"].iter())));
249    }
250
251    #[test]
252    fn a_dependency_that_can_still_run_is_not_stuck() {
253        let i = inv(vec![
254            t("aaaa", TaskStatus::Queued, &[]),
255            t("bbbb", TaskStatus::Held, &[]),
256            t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
257            t("dddd", TaskStatus::Blocked, &["missing1"]),
258            t("eeee", TaskStatus::Blocked, &["cccc"]),
259        ]);
260        assert!(i.stuck().is_empty(), "{:?}", i.stuck());
261    }
262
263    #[test]
264    fn a_done_dependency_does_not_hide_a_held_one() {
265        let i = inv(vec![
266            t("aaaa", TaskStatus::Done, &[]),
267            t("bbbb", TaskStatus::Held, &[]),
268            t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
269        ]);
270        assert_eq!(i.stuck().len(), 1);
271    }
272
273    #[test]
274    fn a_cycle_terminates_and_names_its_smallest_id_from_every_entry() {
275        let i = inv(vec![
276            t("bbbb", TaskStatus::Blocked, &["cccc"]),
277            t("cccc", TaskStatus::Blocked, &["aaaa"]),
278            t("aaaa", TaskStatus::Blocked, &["bbbb"]),
279            t("dddd", TaskStatus::Blocked, &["cccc"]),
280        ]);
281        let stuck = i.stuck();
282        assert_eq!(stuck.len(), 4);
283        for roots in stuck.values() {
284            assert!(roots.iter().eq(["2026-aaaa"].iter()), "{roots:?}");
285        }
286        let d = i.task("2026-dddd").unwrap();
287        assert!(i.waits_on(d)[0].contains("cycle"), "{:?}", i.waits_on(d));
288    }
289
290    #[test]
291    fn a_self_dependency_is_a_stuck_cycle_of_one() {
292        let i = inv(vec![t("aaaa", TaskStatus::Blocked, &["aaaa"])]);
293        assert_eq!(i.stuck().len(), 1);
294    }
295
296    #[test]
297    fn an_open_question_keeps_a_task_alive_and_an_answered_one_resolves() {
298        let mut open = Question::new(
299            "r".into(),
300            "n".into(),
301            "s".into(),
302            "?".into(),
303            String::new(),
304            vec![],
305        );
306        open.id = "2026-qqqq".into();
307        let mut answered = open.clone();
308        answered.id = "2026-rrrr".into();
309        answered.status = QuestionStatus::Answered;
310        let tasks = vec![
311            t("bbbb", TaskStatus::Held, &[]),
312            t("cccc", TaskStatus::Blocked, &["bbbb", "qqqq"]),
313            t("dddd", TaskStatus::Blocked, &["bbbb", "rrrr"]),
314        ];
315        let i = Inventory::new(tasks, &[open, answered]);
316        let stuck = i.stuck();
317        assert!(!stuck.contains_key("2026-cccc"));
318        assert!(stuck.contains_key("2026-dddd"));
319        let c = i.task("2026-cccc").unwrap();
320        assert_eq!(i.waits_on(c)[1], "qqqq (question open)");
321    }
322}