Skip to main content

voro_core/
scheduler.rs

1//! The scheduler (DESIGN.md §7): pure scoring and the ordering of the two
2//! views. The store supplies candidates (with `age_days` already computed);
3//! everything here is deterministic arithmetic on those rows.
4
5use crate::error::Result;
6use crate::model::{Priority, Task, TaskState};
7use crate::store::{Store, task_from_row};
8
9/// The score decomposition — every term visible (§7, §12).
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct ScoreBreakdown {
12    pub weight: i64,
13    pub priority: Priority,
14    pub priority_value: f64,
15    pub state: TaskState,
16    /// Static per-state nudge folded into the priority term (§7).
17    pub state_bonus: f64,
18    /// weight × (priority_value + state_bonus)
19    pub base: f64,
20    pub age_days: f64,
21    /// 0.1 × age_days, capped at 2
22    pub age_bonus: f64,
23    pub total: f64,
24}
25
26/// A static per-state weight folded into the priority term (§7), ranking
27/// human-attention states above plain startable work: `needs-input` (blocks an
28/// idle agent) outweighs `review` and `stalled`; `ready` and `proposed` earn
29/// nothing.
30pub fn state_bonus(state: TaskState) -> f64 {
31    match state {
32        TaskState::NeedsInput => 4.0,
33        TaskState::Review | TaskState::Stalled => 2.0,
34        _ => 0.0,
35    }
36}
37
38pub fn score(weight: i64, priority: Priority, state: TaskState, age_days: f64) -> ScoreBreakdown {
39    let priority_value = priority.value();
40    let state_bonus = state_bonus(state);
41    let base = weight as f64 * (priority_value + state_bonus);
42    let age_bonus = (0.1 * age_days).min(2.0);
43    ScoreBreakdown {
44        weight,
45        priority,
46        priority_value,
47        state,
48        state_bonus,
49        base,
50        age_days,
51        age_bonus,
52        total: base + age_bonus,
53    }
54}
55
56/// A task joined with what the scheduler needs to rank it.
57#[derive(Debug, Clone)]
58pub struct Candidate {
59    pub task: Task,
60    pub project_name: String,
61    pub score: ScoreBreakdown,
62}
63
64/// How many rows the queue offers: enough to pick around the top item, few
65/// enough that the queue stays an answer rather than the whole backlog. A single
66/// cap across every state, since each row is one next action on the same score
67/// (§7).
68pub const QUEUE_MAX_ROWS: usize = 10;
69
70/// The next-action queue (§1): the `QUEUE_MAX_ROWS` highest-scoring tasks
71/// across every actionable state, in one list ordered by score. The cap is
72/// uniform — every state competes for the same slots on score alone, so a
73/// low-scoring row of any state can fall below the cut (§7).
74pub fn queue(candidates: &[Candidate]) -> Vec<&Candidate> {
75    let mut items: Vec<&Candidate> = candidates.iter().collect();
76    items.sort_by(|a, b| rank(a, b));
77    items.truncate(QUEUE_MAX_ROWS);
78    items
79}
80
81/// The single highest-scoring `ready` task — what `voro next` hands an agent
82/// asking for work. Deliberately `ready`-only: a `stalled` task needs
83/// redispatching with its prior session's context, not fresh work (§7).
84pub fn focus(candidates: &[Candidate]) -> Option<&Candidate> {
85    candidates
86        .iter()
87        .filter(|c| c.task.state == TaskState::Ready)
88        .min_by(|a, b| rank(a, b))
89}
90
91/// Total order for views: score desc. Score already folds in the per-state
92/// bonus (§7), so the `state_rank` tiebreak only decides genuinely equal totals,
93/// where an unanswered question outranks a finished diff, startable work, then
94/// an untriaged proposal (§6). Priority, older `state_since`, then id tail it.
95fn rank(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
96    b.score
97        .total
98        .total_cmp(&a.score.total)
99        .then_with(|| state_rank(a.task.state).cmp(&state_rank(b.task.state)))
100        .then_with(|| a.task.priority.cmp(&b.task.priority))
101        .then_with(|| a.task.state_since.cmp(&b.task.state_since))
102        .then_with(|| a.task.id.cmp(&b.task.id))
103}
104
105fn state_rank(state: TaskState) -> u8 {
106    match state {
107        TaskState::NeedsInput => 0,
108        TaskState::Review => 1,
109        TaskState::Stalled => 2,
110        TaskState::Ready => 3,
111        _ => 4,
112    }
113}
114
115impl Store {
116    /// Scheduler input: every task in a scored state, joined with its
117    /// project, excluding weight-0 (parked) projects entirely (§7).
118    pub fn candidates(&self) -> Result<Vec<Candidate>> {
119        let mut stmt = self.conn.prepare(
120            "SELECT t.id, t.project_id, t.title, t.body, t.priority, t.state, t.agent,
121                    t.question, t.pr_url, t.branch, t.state_since, t.created_at, t.closed_at,
122                    t.human, p.name, p.weight,
123                    julianday('now') - julianday(t.state_since)
124             FROM tasks t JOIN projects p ON p.id = t.project_id
125             WHERE p.weight > 0
126               AND t.state IN ('ready','needs-input','review','stalled','proposed')",
127        )?;
128        let rows = stmt.query_map([], |row| {
129            let task = task_from_row(row)?;
130            let project_name: String = row.get(14)?;
131            let weight: i64 = row.get(15)?;
132            let age_days: f64 = row.get(16)?;
133            let score = score(weight, task.priority, task.state, age_days);
134            Ok(Candidate {
135                task,
136                project_name,
137                score,
138            })
139        })?;
140        Ok(rows.collect::<rusqlite::Result<_>>()?)
141    }
142
143    /// Score decomposition for any single task, whatever its state — the
144    /// TUI popup today, `voro explain <task>` later.
145    pub fn explain(&self, task_id: i64) -> Result<ScoreBreakdown> {
146        let (weight, priority, state, age_days): (i64, Priority, TaskState, f64) =
147            self.conn.query_row(
148                "SELECT p.weight, t.priority, t.state,
149                        julianday('now') - julianday(t.state_since)
150             FROM tasks t JOIN projects p ON p.id = t.project_id
151             WHERE t.id = ?1",
152                [task_id],
153                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
154            )?;
155        Ok(score(weight, priority, state, age_days))
156    }
157
158    /// Count of untriaged tasks. Parked (weight-0) projects are hidden
159    /// here too.
160    pub fn proposed_count(&self) -> Result<i64> {
161        Ok(self.state_counts()?.proposed)
162    }
163
164    /// Task counts by state for the header indicator (DESIGN.md §12), so a
165    /// backlog stays felt even when a low-scoring row falls past the queue's
166    /// cap (§7). Parked (weight-0) projects are excluded.
167    pub fn state_counts(&self) -> Result<StateCounts> {
168        let mut stmt = self.conn.prepare(
169            "SELECT t.state, COUNT(*) FROM tasks t JOIN projects p ON p.id = t.project_id
170             WHERE p.weight > 0 GROUP BY t.state",
171        )?;
172        let rows = stmt.query_map([], |r| Ok((r.get::<_, TaskState>(0)?, r.get::<_, i64>(1)?)))?;
173        let mut counts = StateCounts::default();
174        for row in rows {
175            let (state, n) = row?;
176            match state {
177                TaskState::Proposed => counts.proposed = n,
178                TaskState::Ready => counts.ready = n,
179                TaskState::Running => counts.running = n,
180                TaskState::NeedsInput => counts.needs_input = n,
181                TaskState::Review => counts.review = n,
182                TaskState::Waiting => counts.waiting = n,
183                TaskState::Stalled => counts.stalled = n,
184                TaskState::Done => counts.done = n,
185                TaskState::Parked | TaskState::Rejected => {}
186            }
187        }
188        Ok(counts)
189    }
190}
191
192/// Task counts by state, for the persistent header indicator (DESIGN.md §12).
193/// Parked and rejected tasks earn no field.
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
195pub struct StateCounts {
196    pub proposed: i64,
197    pub ready: i64,
198    pub running: i64,
199    pub needs_input: i64,
200    pub review: i64,
201    pub waiting: i64,
202    pub stalled: i64,
203    pub done: i64,
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::model::TaskState;
210    use crate::store::NewTask;
211
212    #[test]
213    fn worked_example_from_design_doc() {
214        // P0 in a weight-2 project (16) beats P2 in a weight-5 project (10).
215        let p0_low_weight = score(2, Priority::P0, TaskState::Ready, 0.0);
216        let p2_high_weight = score(5, Priority::P2, TaskState::Ready, 0.0);
217        assert_eq!(p0_low_weight.total, 16.0);
218        assert_eq!(p2_high_weight.total, 10.0);
219        assert!(p0_low_weight.total > p2_high_weight.total);
220    }
221
222    #[test]
223    fn priority_values_are_geometric() {
224        assert_eq!(score(1, Priority::P0, TaskState::Ready, 0.0).total, 8.0);
225        assert_eq!(score(1, Priority::P1, TaskState::Ready, 0.0).total, 4.0);
226        assert_eq!(score(1, Priority::P2, TaskState::Ready, 0.0).total, 2.0);
227        assert_eq!(score(1, Priority::P3, TaskState::Ready, 0.0).total, 1.0);
228    }
229
230    #[test]
231    fn age_bonus_grows_then_caps_at_two() {
232        assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0).age_bonus, 0.0);
233        assert_eq!(score(3, Priority::P2, TaskState::Ready, 5.0).age_bonus, 0.5);
234        assert_eq!(
235            score(3, Priority::P2, TaskState::Ready, 20.0).age_bonus,
236            2.0
237        );
238        assert_eq!(
239            score(3, Priority::P2, TaskState::Ready, 365.0).age_bonus,
240            2.0
241        );
242        assert_eq!(score(3, Priority::P2, TaskState::Ready, 365.0).total, 8.0);
243    }
244
245    #[test]
246    fn decomposition_terms_sum_to_total() {
247        let s = score(4, Priority::P1, TaskState::Ready, 7.3);
248        assert_eq!(s.base, 16.0);
249        assert_eq!(s.total, s.base + s.age_bonus);
250    }
251
252    #[test]
253    fn state_bonus_folds_into_the_priority_term() {
254        // needs-input +4, review +2, everything else nothing — multiplied by
255        // project weight just like priority.
256        assert_eq!(state_bonus(TaskState::NeedsInput), 4.0);
257        assert_eq!(state_bonus(TaskState::Review), 2.0);
258        assert_eq!(state_bonus(TaskState::Ready), 0.0);
259        assert_eq!(state_bonus(TaskState::Proposed), 0.0);
260
261        // P2 in a weight-3 project: 3×(2+4) = 18 as a question, 3×2 = 6 ready.
262        assert_eq!(
263            score(3, Priority::P2, TaskState::NeedsInput, 0.0).base,
264            18.0
265        );
266        assert_eq!(score(3, Priority::P2, TaskState::Review, 0.0).base, 12.0);
267        assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0).base, 6.0);
268    }
269
270    // --- ordering over a real store ---
271
272    fn setup() -> Store {
273        Store::open_in_memory().unwrap()
274    }
275
276    fn add_project(s: &mut Store, name: &str, weight: i64) -> i64 {
277        let p = s.create_project(name, "/tmp").unwrap();
278        s.set_weight(p.id, weight).unwrap();
279        p.id
280    }
281
282    fn add_task(s: &mut Store, project_id: i64, title: &str, priority: Priority) -> i64 {
283        s.create_task(NewTask {
284            project_id,
285            title: title.into(),
286            body: String::new(),
287            priority,
288            state: TaskState::Ready,
289            agent: None,
290            human: false,
291        })
292        .unwrap()
293        .id
294    }
295
296    fn to_needs_input(s: &mut Store, id: i64) {
297        s.apply(id, crate::Action::Start).unwrap();
298        s.apply(id, crate::Action::Ask("?".into())).unwrap();
299    }
300
301    fn to_review(s: &mut Store, id: i64) {
302        s.apply(id, crate::Action::Start).unwrap();
303        s.apply(id, crate::Action::Complete(None)).unwrap();
304    }
305
306    fn to_stalled(s: &mut Store, id: i64) {
307        let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap();
308        s.reconcile_session(session.id, false, false).unwrap();
309    }
310
311    fn to_waiting(s: &mut Store, id: i64) {
312        s.apply(id, crate::Action::Start).unwrap();
313        s.apply(id, crate::Action::Complete(None)).unwrap();
314        s.apply(id, crate::Action::HandOff).unwrap();
315    }
316
317    fn add_proposed(s: &mut Store, project_id: i64, title: &str, priority: Priority) -> i64 {
318        s.create_task(NewTask {
319            project_id,
320            title: title.into(),
321            body: String::new(),
322            priority,
323            state: TaskState::Proposed,
324            agent: None,
325            human: false,
326        })
327        .unwrap()
328        .id
329    }
330
331    fn set_age_days(s: &mut Store, id: i64, days: f64) {
332        s.conn
333            .execute(
334                "UPDATE tasks SET state_since = datetime('now', ?1 || ' days') WHERE id = ?2",
335                (format!("-{days}"), id),
336            )
337            .unwrap();
338    }
339
340    #[test]
341    fn focus_picks_the_worked_example_winner() {
342        let mut s = setup();
343        let side = add_project(&mut s, "side-project", 2);
344        let main = add_project(&mut s, "main-project", 5);
345        let p0 = add_task(&mut s, side, "urgent fix", Priority::P0);
346        add_task(&mut s, main, "nice to have", Priority::P2);
347
348        let candidates = s.candidates().unwrap();
349        let top = focus(&candidates).unwrap();
350        assert_eq!(top.task.id, p0);
351        assert_eq!(top.score.base, 16.0);
352    }
353
354    #[test]
355    fn queue_interleaves_every_actionable_state_by_score() {
356        let mut s = setup();
357        let a = add_project(&mut s, "a", 3);
358        let b = add_project(&mut s, "b", 1);
359
360        let question = add_task(&mut s, a, "question", Priority::P2); // 3×(2+4) = 18
361        to_needs_input(&mut s, question);
362        let diff = add_task(&mut s, a, "diff", Priority::P0); // 3×(8+2) = 30
363        to_review(&mut s, diff);
364        let small = add_task(&mut s, b, "small question", Priority::P3); // 1×(1+4) = 5
365        to_needs_input(&mut s, small);
366        let ready = add_task(&mut s, a, "ready task", Priority::P1); // 3×4 = 12
367        let proposed = add_proposed(&mut s, a, "proposal", Priority::P2); // 3×2 = 6
368
369        let candidates = s.candidates().unwrap();
370        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
371        // the state bonus lifts the P2 question over the P1 ready task
372        assert_eq!(ids, vec![diff, question, ready, proposed, small]);
373    }
374
375    #[test]
376    fn queue_caps_at_the_highest_scoring_rows() {
377        let mut s = setup();
378        let p = add_project(&mut s, "p", 3);
379        let tasks: Vec<i64> = (0..QUEUE_MAX_ROWS + 4)
380            .map(|i| {
381                let id = add_task(&mut s, p, &format!("t{i}"), Priority::P2);
382                // older tasks score higher via the age bonus, so ordering is
383                // deterministic: index 0 oldest, last youngest.
384                set_age_days(&mut s, id, (QUEUE_MAX_ROWS + 4 - i) as f64);
385                id
386            })
387            .collect();
388
389        let candidates = s.candidates().unwrap();
390        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
391        assert_eq!(ids.len(), QUEUE_MAX_ROWS);
392        assert_eq!(ids, tasks[..QUEUE_MAX_ROWS]);
393    }
394
395    #[test]
396    fn the_cap_drops_a_low_scoring_attention_item_regardless_of_state() {
397        // The cap is uniform across state: a low-scoring question can fall
398        // below it just like a ready task, once enough higher-scoring work
399        // exists. Ten P0 ready tasks in a heavy project (score 40) fill the cap
400        // and push a lone P3 question in a light project (1×(1+4) = 5) off.
401        let mut s = setup();
402        let heavy = add_project(&mut s, "heavy", 5);
403        let light = add_project(&mut s, "light", 1);
404        let loud: Vec<i64> = (0..QUEUE_MAX_ROWS)
405            .map(|i| add_task(&mut s, heavy, &format!("loud {i}"), Priority::P0))
406            .collect();
407        let quiet_question = add_task(&mut s, light, "quiet question", Priority::P3);
408        to_needs_input(&mut s, quiet_question);
409
410        let candidates = s.candidates().unwrap();
411        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
412        assert_eq!(ids.len(), QUEUE_MAX_ROWS);
413        assert!(!ids.contains(&quiet_question));
414        for id in &loud {
415            assert!(ids.contains(id));
416        }
417    }
418
419    #[test]
420    fn state_bonus_lifts_a_question_over_an_equal_priority_review() {
421        // Same weight and priority: the +4 needs-input bonus outscores the +2 review.
422        let mut s = setup();
423        let p = add_project(&mut s, "p", 3);
424        let diff = add_task(&mut s, p, "diff", Priority::P1); // 3×(4+2) = 18
425        to_review(&mut s, diff);
426        let question = add_task(&mut s, p, "question", Priority::P1); // 3×(4+4) = 24
427        to_needs_input(&mut s, question);
428
429        let candidates = s.candidates().unwrap();
430        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
431        assert_eq!(ids, vec![question, diff]);
432    }
433
434    #[test]
435    fn a_stalled_task_scores_the_review_bonus_and_competes_in_the_queue() {
436        // stalled earns +2, the same as review (§7).
437        assert_eq!(state_bonus(TaskState::Stalled), 2.0);
438        assert_eq!(score(3, Priority::P2, TaskState::Stalled, 0.0).base, 12.0);
439
440        // A stalled P2 (3×(2+2) = 12) ties a ready P1 (3×4 = 12) exactly; the
441        // state precedence slots stalled after review, before ready.
442        let mut s = setup();
443        let p = add_project(&mut s, "p", 3);
444        let ready = add_task(&mut s, p, "ready", Priority::P1);
445        let stalled = add_task(&mut s, p, "stalled", Priority::P2);
446        to_stalled(&mut s, stalled);
447        s.conn
448            .execute(
449                "UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
450                (ready, stalled),
451            )
452            .unwrap();
453
454        let candidates = s.candidates().unwrap();
455        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
456        assert_eq!(ids, vec![stalled, ready]);
457    }
458
459    #[test]
460    fn focus_never_hands_out_a_stalled_task() {
461        // `voro next` answers with fresh startable work only, so even a stalled
462        // task that outscores every ready task stays out of focus() while
463        // still leading the queue.
464        let mut s = setup();
465        let p = add_project(&mut s, "p", 3);
466        let stalled = add_task(&mut s, p, "stalled", Priority::P0);
467        to_stalled(&mut s, stalled);
468        let ready = add_task(&mut s, p, "ready", Priority::P3);
469
470        let candidates = s.candidates().unwrap();
471        assert_eq!(focus(&candidates).unwrap().task.id, ready);
472        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
473        assert_eq!(ids, vec![stalled, ready]);
474    }
475
476    #[test]
477    fn waiting_is_excluded_from_the_queue_and_earns_no_bonus() {
478        // `waiting` is out of the running like `parked` (DESIGN.md §6/§7): it
479        // earns no state bonus and never surfaces in the queue or focus, only
480        // in the state counts.
481        assert_eq!(state_bonus(TaskState::Waiting), 0.0);
482
483        let mut s = setup();
484        let p = add_project(&mut s, "p", 5);
485        let waiting = add_task(&mut s, p, "handed off", Priority::P0);
486        to_waiting(&mut s, waiting);
487        let ready = add_task(&mut s, p, "startable", Priority::P3);
488
489        let candidates = s.candidates().unwrap();
490        // even a P0 waiting task in a heavy project stays out of both views
491        assert!(candidates.iter().all(|c| c.task.id != waiting));
492        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
493        assert_eq!(ids, vec![ready]);
494        assert_eq!(focus(&candidates).unwrap().task.id, ready);
495
496        // but it is felt in the state counts
497        assert_eq!(s.state_counts().unwrap().waiting, 1);
498    }
499
500    #[test]
501    fn state_precedence_still_breaks_genuinely_equal_totals() {
502        // Contrived so the folded scores collide: needs-input 3×(1+4) = 15,
503        // review 5×(1+2) = 15. With ages pinned equal the totals tie exactly,
504        // and the state precedence (§6) decides it.
505        let mut s = setup();
506        let a = add_project(&mut s, "a", 3);
507        let b = add_project(&mut s, "b", 5);
508        let diff = add_task(&mut s, b, "diff", Priority::P3);
509        to_review(&mut s, diff);
510        let question = add_task(&mut s, a, "question", Priority::P3);
511        to_needs_input(&mut s, question);
512        s.conn
513            .execute(
514                "UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
515                (diff, question),
516            )
517            .unwrap();
518
519        let candidates = s.candidates().unwrap();
520        let total = |id| {
521            candidates
522                .iter()
523                .find(|c| c.task.id == id)
524                .unwrap()
525                .score
526                .total
527        };
528        assert_eq!(total(diff), total(question));
529        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
530        assert_eq!(ids, vec![question, diff]);
531    }
532
533    #[test]
534    fn age_bonus_breaks_priority_ties_and_starvation() {
535        let mut s = setup();
536        let p = add_project(&mut s, "p", 3);
537        let fresh = add_task(&mut s, p, "fresh", Priority::P2);
538        let stale = add_task(&mut s, p, "stale", Priority::P2);
539        set_age_days(&mut s, stale, 10.0);
540
541        let candidates = s.candidates().unwrap();
542        let top = focus(&candidates).unwrap();
543        assert_eq!(top.task.id, stale);
544        assert!((top.score.age_bonus - 1.0).abs() < 0.01);
545
546        // but capped age can never fake a priority level
547        set_age_days(&mut s, stale, 300.0);
548        let higher = add_task(&mut s, p, "actually urgent", Priority::P1);
549        let candidates = s.candidates().unwrap();
550        assert_eq!(focus(&candidates).unwrap().task.id, higher);
551        let _ = fresh;
552    }
553
554    #[test]
555    fn weight_zero_projects_are_hidden_everywhere() {
556        let mut s = setup();
557        let parked = add_project(&mut s, "parked", 0);
558        let active = add_project(&mut s, "active", 1);
559
560        let hidden_q = add_task(&mut s, parked, "hidden question", Priority::P0);
561        to_needs_input(&mut s, hidden_q);
562        add_task(&mut s, parked, "hidden ready", Priority::P0);
563        s.create_task(NewTask {
564            project_id: parked,
565            title: "hidden proposed".into(),
566            body: String::new(),
567            priority: Priority::P2,
568            state: TaskState::Proposed,
569            agent: None,
570            human: false,
571        })
572        .unwrap();
573        let visible = add_task(&mut s, active, "visible", Priority::P3);
574
575        let candidates = s.candidates().unwrap();
576        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
577        assert_eq!(ids, vec![visible]);
578        assert_eq!(focus(&candidates).unwrap().task.id, visible);
579        assert_eq!(s.proposed_count().unwrap(), 0);
580    }
581
582    #[test]
583    fn state_counts_group_by_state_and_hide_parked_projects() {
584        let mut s = setup();
585        let active = add_project(&mut s, "active", 3);
586        let parked = add_project(&mut s, "parked", 0);
587
588        add_task(&mut s, active, "r1", Priority::P2);
589        add_task(&mut s, active, "r2", Priority::P2);
590        s.create_task(NewTask {
591            project_id: active,
592            title: "idea".into(),
593            body: String::new(),
594            priority: Priority::P2,
595            state: TaskState::Proposed,
596            agent: None,
597            human: false,
598        })
599        .unwrap();
600        let question = add_task(&mut s, active, "blocked on me", Priority::P2);
601        to_needs_input(&mut s, question);
602        let reviewed = add_task(&mut s, active, "in review", Priority::P2);
603        s.apply(reviewed, crate::Action::Start).unwrap();
604        s.apply(reviewed, crate::Action::Complete(None)).unwrap();
605        let stalled = add_task(&mut s, active, "died mid-run", Priority::P2);
606        to_stalled(&mut s, stalled);
607
608        // Everything in a parked (weight-0) project stays out of the tally.
609        add_task(&mut s, parked, "hidden ready", Priority::P2);
610        s.create_task(NewTask {
611            project_id: parked,
612            title: "hidden idea".into(),
613            body: String::new(),
614            priority: Priority::P2,
615            state: TaskState::Proposed,
616            agent: None,
617            human: false,
618        })
619        .unwrap();
620
621        let c = s.state_counts().unwrap();
622        assert_eq!(c.ready, 2);
623        assert_eq!(c.proposed, 1);
624        assert_eq!(c.needs_input, 1);
625        assert_eq!(c.review, 1);
626        assert_eq!(c.stalled, 1);
627        assert_eq!(c.running, 0);
628        assert_eq!(c.done, 0);
629        // proposed_count is the same guard-rail number the counts expose.
630        assert_eq!(s.proposed_count().unwrap(), 1);
631    }
632
633    #[test]
634    fn tasks_with_open_blockers_never_reach_the_queue() {
635        let mut s = setup();
636        let p = add_project(&mut s, "p", 3);
637        let blocker = add_task(&mut s, p, "blocker", Priority::P2);
638        let blocked = add_task(&mut s, p, "blocked", Priority::P0);
639        s.add_dep(blocked, blocker, crate::DepKind::Blocks).unwrap();
640
641        // the high-priority blocked task is out of the running until its
642        // blocker closes — neither view offers it
643        let candidates = s.candidates().unwrap();
644        let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
645        assert_eq!(ids, vec![blocker]);
646        assert_eq!(focus(&candidates).unwrap().task.id, blocker);
647
648        // once the blocker closes it surfaces, and now outranks it
649        s.apply(blocker, crate::Action::Start).unwrap();
650        s.apply(blocker, crate::Action::Complete(None)).unwrap();
651        s.apply(blocker, crate::Action::Accept).unwrap();
652        let candidates = s.candidates().unwrap();
653        assert_eq!(focus(&candidates).unwrap().task.id, blocked);
654    }
655
656    #[test]
657    fn deterministic_tail_ordering() {
658        let mut s = setup();
659        let p = add_project(&mut s, "p", 3);
660        let first = add_task(&mut s, p, "first", Priority::P2);
661        let second = add_task(&mut s, p, "second", Priority::P2);
662
663        let candidates = s.candidates().unwrap();
664        // identical score, state, priority, state_since → id ascending
665        assert_eq!(focus(&candidates).unwrap().task.id, first);
666        let _ = second;
667    }
668}