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        Self::load_with_tasks(queue, questions, queue.list())
77    }
78
79    /// Reuse a listing already read by the caller. Still include tombstones
80    /// and unreadable ids when resolving dependency ambiguity.
81    pub fn load_with_tasks(
82        queue: &crate::queue::Queue,
83        questions: &crate::ask::Questions,
84        tasks: Vec<Task>,
85    ) -> Self {
86        let mut inventory = Self::new(tasks, &questions.list());
87        let ids = (|| -> anyhow::Result<Vec<String>> {
88            let mut ids = crate::queue::record_ids(queue.root(), "json")?;
89            ids.extend(crate::queue::record_ids(questions.root(), "json")?);
90            ids.extend(crate::queue::record_ids(queue.root(), "removed")?);
91            ids.sort();
92            ids.dedup();
93            Ok(ids)
94        })();
95        match ids {
96            Ok(ids) => inventory.ids = ids,
97            Err(_) => inventory.complete = false,
98        }
99        inventory
100    }
101
102    /// Read only the dependency closure for a detail view. Names across both
103    /// stores (including tombstones and corrupt records) still establish
104    /// ambiguity, and unreadable records remain unknown rather than resolved.
105    pub fn load_for(
106        queue: &crate::queue::Queue,
107        questions: &crate::ask::Questions,
108        task: &Task,
109    ) -> Self {
110        Self::load_for_tasks(queue, questions, std::slice::from_ref(task))
111    }
112
113    /// Share id enumeration and dependency reads across one list page.
114    pub fn load_for_tasks(
115        queue: &crate::queue::Queue,
116        questions: &crate::ask::Questions,
117        tasks: &[Task],
118    ) -> Self {
119        let mut inventory = Self::new(tasks.to_vec(), &[]);
120        if tasks.iter().all(|task| task.blocked_by.is_empty()) {
121            return inventory;
122        }
123        let ids = (|| -> anyhow::Result<Vec<String>> {
124            let mut ids = crate::queue::record_ids(queue.root(), "json")?;
125            ids.extend(crate::queue::record_ids(questions.root(), "json")?);
126            ids.extend(crate::queue::record_ids(queue.root(), "removed")?);
127            ids.sort();
128            ids.dedup();
129            Ok(ids)
130        })();
131        match ids {
132            Ok(ids) => inventory.ids = ids,
133            Err(_) => inventory.complete = false,
134        }
135        let mut pending: Vec<_> = tasks
136            .iter()
137            .flat_map(|task| task.blocked_by.iter().cloned())
138            .collect();
139        let mut seen = BTreeSet::new();
140        while let Some(reference) = pending.pop() {
141            let full =
142                if queue.path_of(&reference).is_file() || questions.path_of(&reference).is_file() {
143                    reference
144                } else if inventory.complete {
145                    match crate::queue::resolve_reference(
146                        &reference,
147                        inventory.ids.iter().map(String::as_str),
148                    ) {
149                        crate::queue::Reference::Found(full) => full,
150                        _ => continue,
151                    }
152                } else {
153                    continue;
154                };
155            if !seen.insert(full.clone()) {
156                continue;
157            }
158            if queue.path_of(&full).is_file()
159                && let Ok(t) = queue.get(&full)
160            {
161                pending.extend(t.blocked_by.iter().cloned());
162                inventory.tasks.insert(full.clone(), t);
163            }
164            if questions.path_of(&full).is_file()
165                && let Ok(q) = questions.get(&full)
166            {
167                inventory.questions.insert(full, q.status);
168            }
169        }
170        inventory
171    }
172
173    fn dep(&self, id: &str) -> Dep<'_> {
174        // A partial scan does not invalidate readable exact records. It does
175        // prevent proving that a prefix/suffix is unique across both stores.
176        if let Some(t) = self.tasks.get(id) {
177            return Dep::Task(t);
178        }
179        if let Some(s) = self.questions.get(id) {
180            return Dep::Question(*s);
181        }
182        if !self.complete {
183            return Dep::Unknown;
184        }
185        match crate::queue::resolve_reference(id, self.ids.iter().map(String::as_str)) {
186            crate::queue::Reference::Found(full) => {
187                if let Some(t) = self.tasks.get(&full) {
188                    Dep::Task(t)
189                } else if let Some(s) = self.questions.get(&full) {
190                    Dep::Question(*s)
191                } else {
192                    Dep::Unknown
193                }
194            }
195            crate::queue::Reference::Missing => Dep::Missing,
196            crate::queue::Reference::Ambiguous => Dep::Unknown,
197        }
198    }
199
200    /// A dependency that has nothing left to wait for.
201    fn resolved(&self, id: &str) -> bool {
202        match self.dep(id) {
203            Dep::Task(t) => t.status == TaskStatus::Done,
204            Dep::Question(s) => s == QuestionStatus::Answered,
205            Dep::Missing | Dep::Unknown => false,
206        }
207    }
208
209    fn walk(
210        &self,
211        id: &str,
212        path: &mut Vec<String>,
213        memo: &mut BTreeMap<String, Roots>,
214    ) -> (Roots, bool) {
215        if let Some(v) = memo.get(id) {
216            return (v.clone(), false);
217        }
218        let Some(task) = self.tasks.get(id) else {
219            return (None, false);
220        };
221        path.push(id.to_owned());
222        let mut roots = BTreeSet::new();
223        let mut moving = false;
224        let mut cyclic = false;
225        for b in &task.blocked_by {
226            match self.dep(b) {
227                Dep::Task(t) => match t.status {
228                    TaskStatus::Done => {}
229                    TaskStatus::Held => {
230                        roots.insert(t.id.clone());
231                    }
232                    TaskStatus::Blocked => {
233                        if let Some(pos) = path.iter().position(|p| p == &t.id) {
234                            // A cycle: its smallest id speaks for all of it, so
235                            // every entry point names the same root.
236                            if let Some(min) = path[pos..].iter().min() {
237                                roots.insert(min.clone());
238                            }
239                            cyclic = true;
240                        } else {
241                            let (sub, c) = self.walk(&t.id, path, memo);
242                            cyclic |= c;
243                            match sub {
244                                Some(r) => roots.extend(r),
245                                None => moving = true,
246                            }
247                        }
248                    }
249                    TaskStatus::Queued
250                    | TaskStatus::Running
251                    | TaskStatus::Failed
252                    | TaskStatus::Parked => {
253                        moving = true;
254                    }
255                },
256                Dep::Question(QuestionStatus::Answered) => {}
257                Dep::Question(_) | Dep::Missing | Dep::Unknown => moving = true,
258            }
259        }
260        path.pop();
261        let verdict = (!moving && !roots.is_empty()).then_some(roots);
262        if !cyclic {
263            memo.insert(id.to_owned(), verdict.clone());
264        }
265        (verdict, cyclic)
266    }
267
268    /// Every stuck `blocked` task, with the root ids it is frozen behind.
269    pub fn stuck(&self) -> BTreeMap<String, BTreeSet<String>> {
270        let mut memo = BTreeMap::new();
271        let mut out = BTreeMap::new();
272        for (id, t) in &self.tasks {
273            if t.status != TaskStatus::Blocked {
274                continue;
275            }
276            let (verdict, _) = self.walk(id, &mut Vec::new(), &mut memo);
277            if let Some(roots) = verdict {
278                out.insert(id.clone(), roots);
279            }
280        }
281        out
282    }
283
284    /// The roots `task` is frozen behind; empty when it is not stuck.
285    pub fn stuck_roots(&self, task: &Task) -> BTreeSet<String> {
286        if task.status != TaskStatus::Blocked {
287            return BTreeSet::new();
288        }
289        self.walk(&task.id, &mut Vec::new(), &mut BTreeMap::new())
290            .0
291            .unwrap_or_default()
292    }
293
294    /// Each dependency of `task` with its state, e.g. `9db7 (held)` or
295    /// `4135 (blocked → 9db7 held)`. Empty unless `task` is `blocked`.
296    pub fn waits_on(&self, task: &Task) -> Vec<String> {
297        if task.status != TaskStatus::Blocked {
298            return Vec::new();
299        }
300        task.blocked_by
301            .iter()
302            .map(|b| {
303                let chain = self.chain(b, &mut vec![task.id.clone()]);
304                format!("{} ({chain})", short(b))
305            })
306            .collect()
307    }
308
309    fn chain(&self, id: &str, seen: &mut Vec<String>) -> String {
310        match self.dep(id) {
311            Dep::Missing => "missing".to_owned(),
312            Dep::Unknown => "unavailable or ambiguous".to_owned(),
313            Dep::Question(s) => format!("question {}", s.as_str()),
314            Dep::Task(t) => {
315                let mut out = t.status.as_str().to_owned();
316                if t.status != TaskStatus::Blocked {
317                    return out;
318                }
319                if seen.iter().any(|s| s == &t.id) {
320                    return format!("{out}, cycle");
321                }
322                if seen.len() > CHAIN_DEPTH {
323                    return format!("{out} → …");
324                }
325                seen.push(t.id.clone());
326                if let Some(next) = t.blocked_by.iter().find(|b| !self.resolved(b)) {
327                    out.push_str(&format!(" → {} {}", short(next), self.chain(next, seen)));
328                }
329                out
330            }
331        }
332    }
333
334    /// The task with this id, if it is one.
335    pub fn task(&self, id: &str) -> Option<&Task> {
336        match self.dep(id) {
337            Dep::Task(t) => Some(t),
338            _ => None,
339        }
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::queue::Source;
347    use std::path::PathBuf;
348
349    fn t(id: &str, status: TaskStatus, blocked_by: &[&str]) -> Task {
350        let mut t = Task::new(
351            id.to_owned(),
352            "x".to_owned(),
353            PathBuf::from("/repo"),
354            Source::Human,
355        );
356        t.id = format!("2026-{id}");
357        t.status = status;
358        t.blocked_by = blocked_by.iter().map(|b| format!("2026-{b}")).collect();
359        t
360    }
361
362    fn inv(tasks: Vec<Task>) -> Inventory {
363        Inventory::new(tasks, &[])
364    }
365
366    #[test]
367    fn responsiveness_dependency_closure_matches_the_full_inventory() {
368        let dir = tempfile::tempdir().unwrap();
369        let queue = crate::queue::Queue::at(dir.path().join("queue"));
370        let questions = crate::ask::Questions::at(dir.path().join("questions"));
371        let mut held = t("held", TaskStatus::Held, &[]);
372        let mut middle = t("middle", TaskStatus::Blocked, &["held"]);
373        let mut waiting = t("waiting", TaskStatus::Blocked, &["middle"]);
374        for task in [&mut held, &mut middle, &mut waiting] {
375            queue.put(task).unwrap();
376        }
377        for n in 0..150 {
378            let mut unrelated = t(&format!("noise{n}"), TaskStatus::Done, &[]);
379            unrelated.instruction = "history ".repeat(4096);
380            queue.put(&mut unrelated).unwrap();
381        }
382        for reference in ["2026-middle", "middle", "missing", "collision"] {
383            waiting.blocked_by = vec![reference.into()];
384            queue.put(&mut waiting).unwrap();
385            std::fs::write(queue.root().join("a-collision.json"), "corrupt").unwrap();
386            std::fs::write(queue.root().join("b-collision.removed"), "removed").unwrap();
387            let full = Inventory::load(&queue, &questions);
388            let small = Inventory::load_for(&queue, &questions, &waiting);
389            assert_eq!(small.waits_on(&waiting), full.waits_on(&waiting));
390            assert_eq!(small.stuck_roots(&waiting), full.stuck_roots(&waiting));
391            assert!(small.tasks.len() <= 3);
392        }
393        // A cycle uses the same recursion and root rules as the full inventory.
394        held.status = TaskStatus::Blocked;
395        held.blocked_by = vec![middle.id.clone()];
396        queue.put(&mut held).unwrap();
397        waiting.blocked_by = vec![middle.id.clone()];
398        queue.put(&mut waiting).unwrap();
399        let full = Inventory::load(&queue, &questions);
400        let small = Inventory::load_for(&queue, &questions, &waiting);
401        assert_eq!(small.stuck_roots(&waiting), full.stuck_roots(&waiting));
402    }
403
404    #[test]
405    fn dependency_resolution_partial_scan_keeps_readable_exact_records() {
406        let dir = tempfile::tempdir().unwrap();
407        let queue = crate::queue::Queue::at(dir.path().join("queue"));
408        let questions = crate::ask::Questions::at(dir.path().join("questions"));
409        let mut running = t("feb2", TaskStatus::Running, &[]);
410        let mut middle = t("ca5b", TaskStatus::Blocked, &["feb2"]);
411        let mut waiting = t("ee37", TaskStatus::Blocked, &["ca5b"]);
412        let mut held = t("held", TaskStatus::Held, &[]);
413        let mut short_wait = t("short", TaskStatus::Blocked, &[]);
414        short_wait.blocked_by = vec!["held".into()];
415        for task in [
416            &mut running,
417            &mut middle,
418            &mut waiting,
419            &mut held,
420            &mut short_wait,
421        ] {
422            queue.put(task).unwrap();
423        }
424        // Reading the queue succeeds, while scanning the question store fails.
425        std::fs::write(questions.root(), "not a directory").unwrap();
426        let inventory = Inventory::load(&queue, &questions);
427        assert!(!inventory.complete);
428        assert!(inventory.ids.contains(&running.id));
429        assert_eq!(
430            inventory.task(&running.id).unwrap().status,
431            TaskStatus::Running
432        );
433        assert_eq!(
434            inventory.waits_on(&waiting),
435            ["ca5b (blocked → feb2 running)"]
436        );
437        assert_eq!(
438            inventory.waits_on(&short_wait),
439            ["held (unavailable or ambiguous)"]
440        );
441        assert!(
442            inventory.stuck().is_empty(),
443            "partial scans cannot prove short-id uniqueness"
444        );
445        assert!(matches!(inventory.dep("not-seen"), Dep::Unknown));
446    }
447
448    #[test]
449    fn a_chain_reads_at_a_glance_and_its_root_is_the_held_task() {
450        let i = inv(vec![
451            t("9db7", TaskStatus::Held, &[]),
452            t("4135", TaskStatus::Blocked, &["9db7"]),
453            t("6081", TaskStatus::Blocked, &["4135"]),
454        ]);
455        let six = i.task("2026-6081").unwrap();
456        assert_eq!(i.waits_on(six), ["4135 (blocked → 9db7 held)"]);
457        let four = i.task("2026-4135").unwrap();
458        assert_eq!(i.waits_on(four), ["9db7 (held)"]);
459        let stuck = i.stuck();
460        assert_eq!(stuck.len(), 2);
461        assert!(stuck.values().all(|r| r.iter().eq(["2026-9db7"].iter())));
462    }
463
464    #[test]
465    fn a_dependency_that_can_still_run_is_not_stuck() {
466        let i = inv(vec![
467            t("aaaa", TaskStatus::Queued, &[]),
468            t("bbbb", TaskStatus::Held, &[]),
469            t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
470            t("dddd", TaskStatus::Blocked, &["missing1"]),
471            t("eeee", TaskStatus::Blocked, &["cccc"]),
472        ]);
473        assert!(i.stuck().is_empty(), "{:?}", i.stuck());
474    }
475
476    #[test]
477    fn a_done_dependency_does_not_hide_a_held_one() {
478        let i = inv(vec![
479            t("aaaa", TaskStatus::Done, &[]),
480            t("bbbb", TaskStatus::Held, &[]),
481            t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
482        ]);
483        assert_eq!(i.stuck().len(), 1);
484    }
485
486    #[test]
487    fn a_cycle_terminates_and_names_its_smallest_id_from_every_entry() {
488        let i = inv(vec![
489            t("bbbb", TaskStatus::Blocked, &["cccc"]),
490            t("cccc", TaskStatus::Blocked, &["aaaa"]),
491            t("aaaa", TaskStatus::Blocked, &["bbbb"]),
492            t("dddd", TaskStatus::Blocked, &["cccc"]),
493        ]);
494        let stuck = i.stuck();
495        assert_eq!(stuck.len(), 4);
496        for roots in stuck.values() {
497            assert!(roots.iter().eq(["2026-aaaa"].iter()), "{roots:?}");
498        }
499        let d = i.task("2026-dddd").unwrap();
500        assert!(i.waits_on(d)[0].contains("cycle"), "{:?}", i.waits_on(d));
501    }
502
503    #[test]
504    fn a_self_dependency_is_a_stuck_cycle_of_one() {
505        let i = inv(vec![t("aaaa", TaskStatus::Blocked, &["aaaa"])]);
506        assert_eq!(i.stuck().len(), 1);
507    }
508
509    #[test]
510    fn an_open_question_keeps_a_task_alive_and_an_answered_one_resolves() {
511        let mut open = Question::new(
512            "r".into(),
513            "n".into(),
514            "s".into(),
515            "?".into(),
516            String::new(),
517            vec![],
518        );
519        open.id = "2026-qqqq".into();
520        let mut answered = open.clone();
521        answered.id = "2026-rrrr".into();
522        answered.status = QuestionStatus::Answered;
523        let tasks = vec![
524            t("bbbb", TaskStatus::Held, &[]),
525            t("cccc", TaskStatus::Blocked, &["bbbb", "qqqq"]),
526            t("dddd", TaskStatus::Blocked, &["bbbb", "rrrr"]),
527        ];
528        let i = Inventory::new(tasks, &[open, answered]);
529        let stuck = i.stuck();
530        assert!(!stuck.contains_key("2026-cccc"));
531        assert!(stuck.contains_key("2026-dddd"));
532        let c = i.task("2026-cccc").unwrap();
533        assert_eq!(i.waits_on(c)[1], "qqqq (question open)");
534    }
535}