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::{NextAction, 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    /// Open tasks with a `blocks` dependency on this one (§7).
19    pub open_dependents: i64,
20    /// 1 × open_dependents, capped at 2
21    pub unblock_bonus: f64,
22    /// weight × (priority_value + state_bonus + unblock_bonus)
23    pub base: f64,
24    pub age_days: f64,
25    /// 0.1 × age_days, capped at 2
26    pub age_bonus: f64,
27    pub total: f64,
28}
29
30/// A static per-state weight folded into the priority term (§7), ranking
31/// human-attention states above plain startable work: `needs-input` (blocks an
32/// idle agent) outweighs `review` and `stalled`; `ready` and `proposed` earn
33/// nothing.
34pub fn state_bonus(state: TaskState) -> f64 {
35    match state {
36        TaskState::NeedsInput => 4.0,
37        TaskState::Review | TaskState::Stalled => 2.0,
38        _ => 0.0,
39    }
40}
41
42/// A nudge for a task other open work is parked behind (§7): one point per
43/// direct open `blocks` dependent, capped at two so unblocking never
44/// masquerades as a priority level.
45pub fn unblock_bonus(open_dependents: i64) -> f64 {
46    (open_dependents.max(0) as f64).min(2.0)
47}
48
49pub fn score(
50    weight: i64,
51    priority: Priority,
52    state: TaskState,
53    age_days: f64,
54    open_dependents: i64,
55) -> ScoreBreakdown {
56    let priority_value = priority.value();
57    let state_bonus = state_bonus(state);
58    let unblock_bonus = unblock_bonus(open_dependents);
59    let base = weight as f64 * (priority_value + state_bonus + unblock_bonus);
60    let age_bonus = (0.1 * age_days).min(2.0);
61    ScoreBreakdown {
62        weight,
63        priority,
64        priority_value,
65        state,
66        state_bonus,
67        open_dependents,
68        unblock_bonus,
69        base,
70        age_days,
71        age_bonus,
72        total: base + age_bonus,
73    }
74}
75
76/// A task joined with what the scheduler needs to rank it.
77#[derive(Debug, Clone)]
78pub struct Candidate {
79    pub task: Task,
80    pub project_name: String,
81    pub score: ScoreBreakdown,
82}
83
84/// How many rows the queue offers: enough to pick around the top item, few
85/// enough that the queue stays an answer rather than the whole backlog. A single
86/// cap across every state, since each row is one next action on the same score
87/// (§7).
88pub const QUEUE_MAX_ROWS: usize = 10;
89
90/// How many dispatches may be in flight before the queue stops offering more
91/// (§7). Overridable as `max_running` in `voro.toml`.
92pub const DEFAULT_MAX_RUNNING: i64 = 5;
93
94/// What each next action costs the operator's attention (§7) — the divisor
95/// that turns a raw score into the effective one the queue ranks by. The band
96/// is deliberately narrow, so pricing nudges the order rather than overturning
97/// it: priority still dominates within an action kind.
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct AttentionCosts {
100    /// Answering a question: a decision, not a work session.
101    pub answer: f64,
102    /// Triaging a proposal: a minute at most.
103    pub triage: f64,
104    /// Handing a task to an agent. Near-instant, so its real cost is the
105    /// concurrency slot the WIP gate meters rather than this divisor.
106    pub dispatch: f64,
107    /// Reviewing a diff, locally or on a PR: the expensive one.
108    pub review: f64,
109    /// Doing a human-only task by hand: the most expensive of all.
110    pub human_do: f64,
111}
112
113impl Default for AttentionCosts {
114    fn default() -> AttentionCosts {
115        AttentionCosts {
116            answer: 0.8,
117            triage: 0.8,
118            dispatch: 1.0,
119            review: 1.4,
120            human_do: 1.8,
121        }
122    }
123}
124
125impl AttentionCosts {
126    /// The divisor for one next action. `redispatch` prices as `dispatch`
127    /// because it *is* one — the operator's move is the same keypress, and it
128    /// opens the same session; `pr`, `review PR`, and `open` are the same
129    /// review either way, differing only in the medium the diff arrives on
130    /// (§3). `accept` joins them: on a task that produced no code the summary
131    /// *is* the deliverable, so reading it and deciding is that same review.
132    pub fn of(&self, action: NextAction) -> f64 {
133        match action {
134            NextAction::Answer => self.answer,
135            NextAction::Triage => self.triage,
136            NextAction::Dispatch | NextAction::Redispatch => self.dispatch,
137            NextAction::Pr | NextAction::ReviewPr | NextAction::Open | NextAction::Accept => {
138                self.review
139            }
140            NextAction::Do => self.human_do,
141        }
142    }
143}
144
145/// Whether an action starts an agent, and so spends a concurrency slot rather
146/// than only the operator's attention (§7).
147fn opens_a_session(action: NextAction) -> bool {
148    matches!(action, NextAction::Dispatch | NextAction::Redispatch)
149}
150
151/// The dispatch work-in-progress gate (§7): how many tasks are running against
152/// how many the operator will carry at once.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct WipGate {
155    pub running: i64,
156    pub max_running: i64,
157}
158
159impl WipGate {
160    pub fn at_capacity(&self) -> bool {
161        self.running >= self.max_running
162    }
163}
164
165/// One row of the queue, priced by what it asks of the operator.
166// A task row carries a whole `Candidate` and a digest only a summary, so the
167// variants differ in size; boxing would buy an indirection over a list capped
168// at ten rows.
169#[allow(clippy::large_enum_variant)]
170#[derive(Debug, Clone)]
171pub enum QueueRow {
172    /// A single task and its one next action.
173    Action(ActionRow),
174    /// A project's untriaged proposals, collapsed into one triage row (§7).
175    Digest(DigestRow),
176}
177
178/// A task's row: the candidate, the verb it asks for, and what that verb costs.
179#[derive(Debug, Clone)]
180pub struct ActionRow {
181    pub candidate: Candidate,
182    pub action: NextAction,
183    pub cost: f64,
184    /// `score / cost` — what the queue ranks by.
185    pub effective: f64,
186}
187
188/// One project's proposals, collapsed so a triage backlog cannot swamp the
189/// queue with cheap rows (§7). Scored as its best child, so the digest survives
190/// the cut exactly when that child would have.
191#[derive(Debug, Clone)]
192pub struct DigestRow {
193    pub project_name: String,
194    /// The constituent proposals, in the order they would have ranked.
195    pub tasks: Vec<ActionRow>,
196    pub effective: f64,
197}
198
199/// The queue as rendered: its rows, plus the dispatch gate's state when it is
200/// suppressing rows (§7).
201#[derive(Debug, Clone)]
202pub struct Queue {
203    pub rows: Vec<QueueRow>,
204    /// `Some` while dispatch is at capacity, carrying the counts the capacity
205    /// line names in place of the suppressed rows.
206    pub at_capacity: Option<WipGate>,
207}
208
209impl QueueRow {
210    pub fn effective(&self) -> f64 {
211        match self {
212            QueueRow::Action(row) => row.effective,
213            QueueRow::Digest(row) => row.effective,
214        }
215    }
216
217    /// The candidate a row's tie-break reads: its own for a task row, its best
218    /// child's for a digest.
219    fn ranking_candidate(&self) -> Option<&Candidate> {
220        match self {
221            QueueRow::Action(row) => Some(&row.candidate),
222            QueueRow::Digest(row) => row.tasks.first().map(|row| &row.candidate),
223        }
224    }
225}
226
227/// The next-action queue (§1): the `QUEUE_MAX_ROWS` highest-*effective*-scoring
228/// next actions, in one list. The cap is uniform — every state competes for the
229/// same slots, so a low-scoring row of any kind can fall below the cut (§7) —
230/// but what competes is the attention price `score / cost(action)`, so a cheap
231/// decision outranks an expensive review of the same raw worth.
232///
233/// Two rows are not priced but shaped: dispatch is metered by the WIP gate
234/// rather than a divisor, so at capacity its rows leave the queue entirely; and
235/// proposals collapse into one digest row per project, since at a divisor below
236/// one a large triage backlog would otherwise crowd out everything else.
237pub fn queue(candidates: &[Candidate], costs: &AttentionCosts, gate: WipGate) -> Queue {
238    let at_capacity = gate.at_capacity();
239    let mut actions: Vec<ActionRow> = Vec::new();
240    for candidate in candidates {
241        let Some(action) = candidate.task.next_action() else {
242            continue;
243        };
244        if at_capacity && opens_a_session(action) {
245            continue;
246        }
247        let cost = costs.of(action);
248        actions.push(ActionRow {
249            effective: candidate.score.total / cost,
250            candidate: candidate.clone(),
251            action,
252            cost,
253        });
254    }
255
256    let mut rows = collapse_proposals(actions);
257    rows.sort_by(rank_rows);
258    rows.truncate(QUEUE_MAX_ROWS);
259    Queue {
260        rows,
261        at_capacity: at_capacity.then_some(gate),
262    }
263}
264
265/// Fold every triage row into one digest per project, leaving the rest as they
266/// are. Each digest takes its best child's effective score, so it competes for
267/// a slot exactly as that child would have.
268fn collapse_proposals(actions: Vec<ActionRow>) -> Vec<QueueRow> {
269    let mut by_project: Vec<(String, Vec<ActionRow>)> = Vec::new();
270    let mut rows: Vec<QueueRow> = Vec::new();
271    for row in actions {
272        if row.action != NextAction::Triage {
273            rows.push(QueueRow::Action(row));
274            continue;
275        }
276        let project = row.candidate.project_name.clone();
277        match by_project.iter_mut().find(|(name, _)| *name == project) {
278            Some((_, tasks)) => tasks.push(row),
279            None => by_project.push((project, vec![row])),
280        }
281    }
282    rows.extend(by_project.into_iter().map(|(project_name, mut tasks)| {
283        tasks.sort_by(|a, b| rank(&a.candidate, &b.candidate));
284        let effective = tasks
285            .iter()
286            .map(|row| row.effective)
287            .fold(f64::NEG_INFINITY, f64::max);
288        QueueRow::Digest(DigestRow {
289            project_name,
290            tasks,
291            effective,
292        })
293    }));
294    rows
295}
296
297/// Total order for the queue: effective score desc, then the same tie-break
298/// chain the raw score uses (§6/§7). A digest breaks ties on its best child, so
299/// it sits exactly where that child would have.
300fn rank_rows(a: &QueueRow, b: &QueueRow) -> std::cmp::Ordering {
301    b.effective().total_cmp(&a.effective()).then_with(|| {
302        match (a.ranking_candidate(), b.ranking_candidate()) {
303            (Some(a), Some(b)) => rank(a, b),
304            (a, b) => a.is_none().cmp(&b.is_none()),
305        }
306    })
307}
308
309/// What a task's raw score becomes once priced by its next action (§7) — the
310/// division `explain` and the TUI decomposition show beside the total.
311#[derive(Debug, Clone, Copy, PartialEq)]
312pub struct EffectiveScore {
313    pub action: NextAction,
314    pub cost: f64,
315    pub effective: f64,
316}
317
318/// The attention price of one task, or `None` for a state that asks nothing of
319/// the operator and so never renders a queue row (§3).
320pub fn effective_score(task: &Task, total: f64, costs: &AttentionCosts) -> Option<EffectiveScore> {
321    let action = task.next_action()?;
322    let cost = costs.of(action);
323    Some(EffectiveScore {
324        action,
325        cost,
326        effective: total / cost,
327    })
328}
329
330/// The single highest-scoring `ready` task — what `voro next` hands an agent
331/// asking for work. Deliberately `ready`-only: a `stalled` task needs
332/// redispatching with its prior session's context, not fresh work (§7).
333pub fn focus(candidates: &[Candidate]) -> Option<&Candidate> {
334    candidates
335        .iter()
336        .filter(|c| c.task.state == TaskState::Ready)
337        .min_by(|a, b| rank(a, b))
338}
339
340/// Total order for views: score desc. Score already folds in the per-state
341/// bonus (§7), so the `state_rank` tiebreak only decides genuinely equal totals,
342/// where an unanswered question outranks a finished diff, startable work, then
343/// an untriaged proposal (§6). Priority, older `state_since`, then id tail it.
344fn rank(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
345    b.score
346        .total
347        .total_cmp(&a.score.total)
348        .then_with(|| state_rank(a.task.state).cmp(&state_rank(b.task.state)))
349        .then_with(|| a.task.priority.cmp(&b.task.priority))
350        .then_with(|| a.task.state_since.cmp(&b.task.state_since))
351        .then_with(|| a.task.id.cmp(&b.task.id))
352}
353
354fn state_rank(state: TaskState) -> u8 {
355    match state {
356        TaskState::NeedsInput => 0,
357        TaskState::Review => 1,
358        TaskState::Stalled => 2,
359        TaskState::Ready => 3,
360        _ => 4,
361    }
362}
363
364impl Store {
365    /// Scheduler input: every task in a scored state, joined with its
366    /// project, excluding weight-0 (parked) and archived projects entirely
367    /// (§5/§7). The open-dependent count arrives as one grouped join, not a
368    /// lookup per row.
369    pub fn candidates(&self) -> Result<Vec<Candidate>> {
370        let mut stmt = self.conn.prepare(
371            "SELECT t.id, t.project_id, t.title, t.body, t.priority, t.state, t.agent,
372                    t.question, t.pr_url, t.branch, t.state_since, t.created_at, t.closed_at,
373                    t.human, t.repo_id, t.deep, p.name, p.weight,
374                    julianday('now') - julianday(t.state_since),
375                    COALESCE(b.open_dependents, 0)
376             FROM tasks t JOIN projects p ON p.id = t.project_id
377             LEFT JOIN (SELECT d.depends_on AS blocker_id, COUNT(*) AS open_dependents
378                        FROM deps d JOIN tasks dt ON dt.id = d.task_id
379                        WHERE d.kind = 'blocks' AND dt.state NOT IN ('done','rejected')
380                        GROUP BY d.depends_on) b ON b.blocker_id = t.id
381             WHERE p.weight > 0 AND p.archived = 0
382               AND t.state IN ('ready','needs-input','review','stalled','proposed')",
383        )?;
384        let rows = stmt.query_map([], |row| {
385            let task = task_from_row(row)?;
386            let project_name: String = row.get(16)?;
387            let weight: i64 = row.get(17)?;
388            let age_days: f64 = row.get(18)?;
389            let open_dependents: i64 = row.get(19)?;
390            let score = score(weight, task.priority, task.state, age_days, open_dependents);
391            Ok(Candidate {
392                task,
393                project_name,
394                score,
395            })
396        })?;
397        Ok(rows.collect::<rusqlite::Result<_>>()?)
398    }
399
400    /// Score decomposition for any single task, whatever its state — the
401    /// TUI popup today, `voro explain <task>` later.
402    pub fn explain(&self, task_id: i64) -> Result<ScoreBreakdown> {
403        let (weight, priority, state, age_days, open_dependents): (
404            i64,
405            Priority,
406            TaskState,
407            f64,
408            i64,
409        ) = self.conn.query_row(
410            "SELECT p.weight, t.priority, t.state,
411                    julianday('now') - julianday(t.state_since),
412                    (SELECT COUNT(*) FROM deps d JOIN tasks dt ON dt.id = d.task_id
413                     WHERE d.depends_on = t.id AND d.kind = 'blocks'
414                       AND dt.state NOT IN ('done','rejected'))
415             FROM tasks t JOIN projects p ON p.id = t.project_id
416             WHERE t.id = ?1",
417            [task_id],
418            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
419        )?;
420        Ok(score(weight, priority, state, age_days, open_dependents))
421    }
422
423    /// Count of untriaged tasks. Parked (weight-0) and archived projects are
424    /// hidden here too.
425    pub fn proposed_count(&self) -> Result<i64> {
426        Ok(self.state_counts()?.proposed)
427    }
428
429    /// Task counts by state for the header indicator (DESIGN.md §12), so a
430    /// backlog stays felt even when a low-scoring row falls past the queue's
431    /// cap (§7). Parked (weight-0) and archived projects are excluded.
432    pub fn state_counts(&self) -> Result<StateCounts> {
433        let mut stmt = self.conn.prepare(
434            "SELECT t.state, COUNT(*) FROM tasks t JOIN projects p ON p.id = t.project_id
435             WHERE p.weight > 0 AND p.archived = 0 GROUP BY t.state",
436        )?;
437        let rows = stmt.query_map([], |r| Ok((r.get::<_, TaskState>(0)?, r.get::<_, i64>(1)?)))?;
438        let mut counts = StateCounts::default();
439        for row in rows {
440            let (state, n) = row?;
441            match state {
442                TaskState::Proposed => counts.proposed = n,
443                TaskState::Refining => counts.refining = n,
444                TaskState::Ready => counts.ready = n,
445                TaskState::Running => counts.running = n,
446                TaskState::NeedsInput => counts.needs_input = n,
447                TaskState::Review => counts.review = n,
448                TaskState::Waiting => counts.waiting = n,
449                TaskState::Stalled => counts.stalled = n,
450                TaskState::Done => counts.done = n,
451                TaskState::Parked | TaskState::Rejected => {}
452            }
453        }
454        Ok(counts)
455    }
456}
457
458/// Task counts by state, for the persistent header indicator (DESIGN.md §12).
459/// Parked and rejected tasks earn no field.
460#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
461pub struct StateCounts {
462    pub proposed: i64,
463    /// Proposals an agent is rewriting right now (DESIGN.md §6). Counted apart
464    /// from `proposed`, which they have temporarily left, so the triage backlog
465    /// stays felt while a round is in flight.
466    pub refining: i64,
467    pub ready: i64,
468    pub running: i64,
469    pub needs_input: i64,
470    pub review: i64,
471    pub waiting: i64,
472    pub stalled: i64,
473    pub done: i64,
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::model::{LivenessSource, TaskState};
480    use crate::store::NewTask;
481
482    #[test]
483    fn worked_example_from_design_doc() {
484        // P0 in a weight-2 project (16) beats P2 in a weight-5 project (10).
485        let p0_low_weight = score(2, Priority::P0, TaskState::Ready, 0.0, 0);
486        let p2_high_weight = score(5, Priority::P2, TaskState::Ready, 0.0, 0);
487        assert_eq!(p0_low_weight.total, 16.0);
488        assert_eq!(p2_high_weight.total, 10.0);
489        assert!(p0_low_weight.total > p2_high_weight.total);
490    }
491
492    #[test]
493    fn priority_values_are_geometric() {
494        assert_eq!(score(1, Priority::P0, TaskState::Ready, 0.0, 0).total, 8.0);
495        assert_eq!(score(1, Priority::P1, TaskState::Ready, 0.0, 0).total, 4.0);
496        assert_eq!(score(1, Priority::P2, TaskState::Ready, 0.0, 0).total, 2.0);
497        assert_eq!(score(1, Priority::P3, TaskState::Ready, 0.0, 0).total, 1.0);
498    }
499
500    #[test]
501    fn age_bonus_grows_then_caps_at_two() {
502        assert_eq!(
503            score(3, Priority::P2, TaskState::Ready, 0.0, 0).age_bonus,
504            0.0
505        );
506        assert_eq!(
507            score(3, Priority::P2, TaskState::Ready, 5.0, 0).age_bonus,
508            0.5
509        );
510        assert_eq!(
511            score(3, Priority::P2, TaskState::Ready, 20.0, 0).age_bonus,
512            2.0
513        );
514        assert_eq!(
515            score(3, Priority::P2, TaskState::Ready, 365.0, 0).age_bonus,
516            2.0
517        );
518        assert_eq!(
519            score(3, Priority::P2, TaskState::Ready, 365.0, 0).total,
520            8.0
521        );
522    }
523
524    #[test]
525    fn decomposition_terms_sum_to_total() {
526        let s = score(4, Priority::P1, TaskState::Ready, 7.3, 0);
527        assert_eq!(s.base, 16.0);
528        assert_eq!(s.total, s.base + s.age_bonus);
529    }
530
531    #[test]
532    fn unblock_bonus_counts_dependents_and_caps_at_two() {
533        assert_eq!(unblock_bonus(0), 0.0);
534        assert_eq!(unblock_bonus(1), 1.0);
535        assert_eq!(unblock_bonus(2), 2.0);
536        assert_eq!(unblock_bonus(9), 2.0);
537
538        // priced inside the weight multiply like the state bonus: a P2 in a
539        // weight-3 project is 3×2 = 6 blocking nothing, 3×(2+1) = 9 blocking
540        // one task, 3×(2+2) = 12 blocking three or more.
541        assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0, 0).base, 6.0);
542        assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0, 1).base, 9.0);
543        assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0, 3).base, 12.0);
544
545        // and it stays a decomposition term the total accounts for
546        let s = score(3, Priority::P2, TaskState::Ready, 10.0, 1);
547        assert_eq!(s.open_dependents, 1);
548        assert_eq!(s.unblock_bonus, 1.0);
549        assert_eq!(s.total, s.base + s.age_bonus);
550    }
551
552    #[test]
553    fn the_unblock_bonus_applies_to_every_scored_state() {
554        // like the age bonus: a dependency edge is an operator/graph fact, so
555        // even an untriaged proposal earns it (§7).
556        for state in [
557            TaskState::Ready,
558            TaskState::NeedsInput,
559            TaskState::Review,
560            TaskState::Stalled,
561            TaskState::Proposed,
562        ] {
563            let plain = score(2, Priority::P2, state, 0.0, 0);
564            let blocking = score(2, Priority::P2, state, 0.0, 1);
565            assert_eq!(blocking.base - plain.base, 2.0, "{state}");
566        }
567    }
568
569    #[test]
570    fn state_bonus_folds_into_the_priority_term() {
571        // needs-input +4, review +2, everything else nothing — multiplied by
572        // project weight just like priority.
573        assert_eq!(state_bonus(TaskState::NeedsInput), 4.0);
574        assert_eq!(state_bonus(TaskState::Review), 2.0);
575        assert_eq!(state_bonus(TaskState::Ready), 0.0);
576        assert_eq!(state_bonus(TaskState::Proposed), 0.0);
577
578        // P2 in a weight-3 project: 3×(2+4) = 18 as a question, 3×2 = 6 ready.
579        assert_eq!(
580            score(3, Priority::P2, TaskState::NeedsInput, 0.0, 0).base,
581            18.0
582        );
583        assert_eq!(score(3, Priority::P2, TaskState::Review, 0.0, 0).base, 12.0);
584        assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0, 0).base, 6.0);
585    }
586
587    // --- ordering over a real store ---
588
589    /// The gate with nothing in flight — the ordering tests are about pricing,
590    /// not capacity, so they run with room to dispatch.
591    fn open_gate() -> WipGate {
592        WipGate {
593            running: 0,
594            max_running: DEFAULT_MAX_RUNNING,
595        }
596    }
597
598    fn default_queue(s: &Store) -> Queue {
599        queue(
600            &s.candidates().unwrap(),
601            &AttentionCosts::default(),
602            open_gate(),
603        )
604    }
605
606    /// Each row in order: a task row as its id, a digest as its project and
607    /// count, so an assertion can name both kinds in one list.
608    fn labels(q: &Queue) -> Vec<String> {
609        q.rows
610            .iter()
611            .map(|row| match row {
612                QueueRow::Action(row) => format!("#{}", row.candidate.task.id),
613                QueueRow::Digest(d) => format!("▲{} {}", d.tasks.len(), d.project_name),
614            })
615            .collect()
616    }
617
618    /// Only the task rows, by id — for the many tests where no proposal is in
619    /// play and ids read better than labels.
620    fn task_ids(q: &Queue) -> Vec<i64> {
621        q.rows
622            .iter()
623            .filter_map(|row| match row {
624                QueueRow::Action(row) => Some(row.candidate.task.id),
625                QueueRow::Digest(_) => None,
626            })
627            .collect()
628    }
629
630    fn setup() -> Store {
631        Store::open_in_memory().unwrap()
632    }
633
634    fn add_project(s: &mut Store, name: &str, weight: i64) -> i64 {
635        let p = s.create_project(name, "/tmp").unwrap();
636        s.set_weight(p.id, weight).unwrap();
637        p.id
638    }
639
640    fn add_task(s: &mut Store, project_id: i64, title: &str, priority: Priority) -> i64 {
641        s.create_task(NewTask {
642            project_id,
643            repo_id: None,
644            title: title.into(),
645            body: String::new(),
646            priority,
647            state: TaskState::Ready,
648            agent: None,
649            human: false,
650            deep: false,
651        })
652        .unwrap()
653        .id
654    }
655
656    fn to_needs_input(s: &mut Store, id: i64) {
657        s.apply(id, crate::Action::Start).unwrap();
658        s.apply(id, crate::Action::Ask("?".into())).unwrap();
659    }
660
661    fn to_review(s: &mut Store, id: i64) {
662        s.apply(id, crate::Action::Start).unwrap();
663        s.apply(id, crate::Action::Complete(None)).unwrap();
664    }
665
666    fn to_stalled(s: &mut Store, id: i64) {
667        let (_, session) = s
668            .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
669            .unwrap();
670        s.reconcile_session(session.id, false, false).unwrap();
671    }
672
673    fn to_waiting(s: &mut Store, id: i64) {
674        s.apply(id, crate::Action::Start).unwrap();
675        s.apply(id, crate::Action::Complete(None)).unwrap();
676        s.apply(id, crate::Action::HandOff).unwrap();
677    }
678
679    fn add_proposed(s: &mut Store, project_id: i64, title: &str, priority: Priority) -> i64 {
680        s.create_task(NewTask {
681            project_id,
682            repo_id: None,
683            title: title.into(),
684            body: String::new(),
685            priority,
686            state: TaskState::Proposed,
687            agent: None,
688            human: false,
689            deep: false,
690        })
691        .unwrap()
692        .id
693    }
694
695    fn set_age_days(s: &mut Store, id: i64, days: f64) {
696        s.conn
697            .execute(
698                "UPDATE tasks SET state_since = datetime('now', ?1 || ' days') WHERE id = ?2",
699                (format!("-{days}"), id),
700            )
701            .unwrap();
702    }
703
704    #[test]
705    fn focus_picks_the_worked_example_winner() {
706        let mut s = setup();
707        let side = add_project(&mut s, "side-project", 2);
708        let main = add_project(&mut s, "main-project", 5);
709        let p0 = add_task(&mut s, side, "urgent fix", Priority::P0);
710        add_task(&mut s, main, "nice to have", Priority::P2);
711
712        let candidates = s.candidates().unwrap();
713        let top = focus(&candidates).unwrap();
714        assert_eq!(top.task.id, p0);
715        assert_eq!(top.score.base, 16.0);
716    }
717
718    #[test]
719    fn queue_interleaves_every_actionable_state_by_attention_price() {
720        let mut s = setup();
721        let a = add_project(&mut s, "a", 3);
722        let b = add_project(&mut s, "b", 1);
723
724        let question = add_task(&mut s, a, "question", Priority::P2); // 18 ÷0.8 = 22.5
725        to_needs_input(&mut s, question);
726        let diff = add_task(&mut s, a, "diff", Priority::P0); // 30 ÷1.4 = 21.4
727        to_review(&mut s, diff);
728        let small = add_task(&mut s, b, "small question", Priority::P3); // 5 ÷0.8 = 6.25
729        to_needs_input(&mut s, small);
730        let ready = add_task(&mut s, a, "ready task", Priority::P1); // 12 ÷1.0 = 12
731        add_proposed(&mut s, a, "proposal", Priority::P2); // 6 ÷0.8 = 7.5, digested
732
733        let rows = labels(&default_queue(&s));
734        // The P0 review leads on raw score (30 against the question's 18) and
735        // still loses the top row: 15–60 minutes of attention against a
736        // decision. Priority keeps its grip inside each kind — the P2 question
737        // is far above the P3 one — and the proposals ride as one digest row.
738        assert_eq!(
739            rows,
740            vec![
741                format!("#{question}"),
742                format!("#{diff}"),
743                format!("#{ready}"),
744                "▲1 a".to_string(),
745                format!("#{small}"),
746            ]
747        );
748    }
749
750    /// Reading a diff costs the same whatever medium it arrives on, so a row
751    /// whose `pr` degraded to `open` (DESIGN.md §8) keeps its place in the
752    /// queue — the advertisement changed, not the price.
753    #[test]
754    fn the_local_review_path_prices_as_a_review() {
755        let costs = AttentionCosts::default();
756        assert_eq!(costs.of(NextAction::Open), costs.of(NextAction::Pr));
757        // and so does a report with no diff at all (DESIGN.md §6): reading what
758        // came back and deciding on it is the same operator move
759        assert_eq!(costs.of(NextAction::Accept), costs.of(NextAction::Pr));
760    }
761
762    #[test]
763    fn the_cost_band_stays_a_nudge_not_a_re_ranking() {
764        // The worked example from DESIGN.md §7: within one project and one
765        // weight, a P2 review (8.4 ÷1.4 = 6.0) ranks below a P2 triage digest
766        // (8.4 ÷0.8 = 10.5) but above a P3 one (4.2 ÷0.8 = 5.25) — priority
767        // still dominates the action kind.
768        let mut s = setup();
769        let p = add_project(&mut s, "p", 4);
770        let other = add_project(&mut s, "other", 4);
771        let diff = add_task(&mut s, p, "diff", Priority::P2); // 4×(2+2) = 16 ÷1.4 = 11.4
772        to_review(&mut s, diff);
773        add_proposed(&mut s, p, "close idea", Priority::P2); // 4×2 = 8 ÷0.8 = 10
774        add_proposed(&mut s, other, "distant idea", Priority::P3); // 4×1 = 4 ÷0.8 = 5
775
776        assert_eq!(
777            labels(&default_queue(&s)),
778            vec![
779                format!("#{diff}"),
780                "▲1 p".to_string(),
781                "▲1 other".to_string()
782            ]
783        );
784
785        // A P1 review (4×(4+2) = 24 ÷1.4 = 17.1) still beats the P2 digest —
786        // one priority level is worth more than the whole cost band.
787        let urgent = add_task(&mut s, p, "urgent diff", Priority::P1);
788        to_review(&mut s, urgent);
789        assert_eq!(labels(&default_queue(&s))[0], format!("#{urgent}"));
790    }
791
792    #[test]
793    fn a_human_task_is_priced_above_a_dispatch_of_the_same_worth() {
794        // `do` is the most expensive row there is: the operator executes it
795        // personally, where a dispatch is a keypress (§7).
796        let mut s = setup();
797        let p = add_project(&mut s, "p", 3);
798        let by_hand = add_task(&mut s, p, "solder the harness", Priority::P1);
799        let existing = s.task(by_hand).unwrap();
800        s.update_task(
801            by_hand,
802            crate::TaskEdit {
803                title: existing.title.clone(),
804                body: existing.body.clone(),
805                priority: existing.priority,
806                agent: None,
807                human: true,
808                deep: false,
809            },
810        )
811        .unwrap();
812        let dispatchable = add_task(&mut s, p, "write the driver", Priority::P1);
813
814        // Identical raw score (3×4 = 12); the divisors split them 6.67 to 12.
815        assert_eq!(
816            labels(&default_queue(&s)),
817            vec![format!("#{dispatchable}"), format!("#{by_hand}"),]
818        );
819    }
820
821    // --- the dispatch WIP gate (§7) ---
822
823    #[test]
824    fn the_wip_gate_suppresses_dispatch_rows_only_at_the_cap() {
825        // Dispatch is near-instant for the operator, so its true cost is the
826        // concurrency slot, not attention: below the cap it is priced at 1.0
827        // like anything else, and at the cap it leaves the queue entirely.
828        let mut s = setup();
829        let p = add_project(&mut s, "p", 3);
830        let ready = add_task(&mut s, p, "startable", Priority::P0);
831        let stalled = add_task(&mut s, p, "died mid-run", Priority::P0);
832        to_stalled(&mut s, stalled);
833        let question = add_task(&mut s, p, "question", Priority::P3);
834        to_needs_input(&mut s, question);
835
836        let at = |running, max_running| {
837            queue(
838                &s.candidates().unwrap(),
839                &AttentionCosts::default(),
840                WipGate {
841                    running,
842                    max_running,
843                },
844            )
845        };
846
847        // One below the cap: everything competes.
848        let below = at(1, 2);
849        assert_eq!(below.at_capacity, None);
850        assert_eq!(task_ids(&below), vec![stalled, ready, question]);
851
852        // At the cap: both rows that would open a session are gone —
853        // redispatch is a dispatch, it just carries the dead session's context
854        // — and the capacity line stands in for them.
855        let at_cap = at(2, 2);
856        assert_eq!(task_ids(&at_cap), vec![question]);
857        assert_eq!(
858            at_cap.at_capacity,
859            Some(WipGate {
860                running: 2,
861                max_running: 2
862            })
863        );
864
865        // Over the cap reads the same as at it; the gate is a floor, not an
866        // equality (a hand-started task can put the fleet over).
867        assert!(at(5, 2).at_capacity.is_some());
868        assert_eq!(task_ids(&at(5, 2)), vec![question]);
869    }
870
871    #[test]
872    fn the_wip_gate_leaves_a_human_task_alone() {
873        // `do` spends the operator's hands, not a concurrency slot, so a
874        // full fleet says nothing about whether it can be picked up.
875        let mut s = setup();
876        let p = add_project(&mut s, "p", 3);
877        let by_hand = add_task(&mut s, p, "drive to the lab", Priority::P2);
878        let existing = s.task(by_hand).unwrap();
879        s.update_task(
880            by_hand,
881            crate::TaskEdit {
882                title: existing.title.clone(),
883                body: existing.body.clone(),
884                priority: existing.priority,
885                agent: None,
886                human: true,
887                deep: false,
888            },
889        )
890        .unwrap();
891        add_task(&mut s, p, "dispatchable", Priority::P0);
892
893        let q = queue(
894            &s.candidates().unwrap(),
895            &AttentionCosts::default(),
896            WipGate {
897                running: 9,
898                max_running: 5,
899            },
900        );
901        assert_eq!(task_ids(&q), vec![by_hand]);
902    }
903
904    #[test]
905    fn max_running_zero_stops_the_queue_offering_dispatches() {
906        let mut s = setup();
907        let p = add_project(&mut s, "p", 3);
908        add_task(&mut s, p, "startable", Priority::P0);
909
910        let q = queue(
911            &s.candidates().unwrap(),
912            &AttentionCosts::default(),
913            WipGate {
914                running: 0,
915                max_running: 0,
916            },
917        );
918        assert!(q.rows.is_empty());
919        assert!(q.at_capacity.is_some());
920    }
921
922    // --- the proposal digest (§7) ---
923
924    #[test]
925    fn proposals_collapse_into_one_digest_scored_as_its_best_child() {
926        // Cheap rows must not swamp the queue: nine proposals at ÷0.8 would
927        // otherwise fill it. One digest per project, scored as the best child,
928        // so it survives the cut exactly when that child would have.
929        let mut s = setup();
930        let p = add_project(&mut s, "p", 3);
931        let other = add_project(&mut s, "other", 3);
932        let best = add_proposed(&mut s, p, "the good idea", Priority::P0); // 24 ÷0.8 = 30
933        for i in 0..8 {
934            add_proposed(&mut s, p, &format!("idea {i}"), Priority::P3);
935        }
936        add_proposed(&mut s, other, "elsewhere", Priority::P2);
937
938        let q = default_queue(&s);
939        assert_eq!(labels(&q), vec!["▲9 p", "▲1 other"]);
940        let QueueRow::Digest(digest) = &q.rows[0] else {
941            panic!("expected a digest, got {:?}", q.rows[0]);
942        };
943        // 24 ÷0.8, give or take the age bonus these tasks accrue as the test runs
944        assert!(
945            (digest.effective - 30.0).abs() < 0.1,
946            "{}",
947            digest.effective
948        );
949        // Its children are ordered as they would have ranked, best first, so
950        // folding it open opens on the proposal worth triaging.
951        assert_eq!(digest.tasks[0].candidate.task.id, best);
952        // and no proposal renders as a row of its own
953        assert!(task_ids(&q).is_empty());
954    }
955
956    #[test]
957    fn a_digest_falls_below_the_cap_exactly_as_its_best_child_would() {
958        let mut s = setup();
959        let heavy = add_project(&mut s, "heavy", 5);
960        let light = add_project(&mut s, "light", 1);
961        for i in 0..QUEUE_MAX_ROWS {
962            add_task(&mut s, heavy, &format!("loud {i}"), Priority::P3); // 5×1 = 5 ÷1.0
963        }
964        // 1×1 = 1 ÷0.8 = 1.25 against ten rows at 5: below the cut.
965        add_proposed(&mut s, light, "quiet idea", Priority::P3);
966
967        let q = default_queue(&s);
968        assert_eq!(q.rows.len(), QUEUE_MAX_ROWS);
969        assert!(!labels(&q).iter().any(|l| l.starts_with('▲')));
970
971        // Raise the same proposal's priority (1×8 = 8 ÷0.8 = 10) and its
972        // digest earns a row, displacing one of the ten.
973        let loud_idea = add_proposed(&mut s, light, "loud idea", Priority::P0);
974        let q = default_queue(&s);
975        assert!(labels(&q).contains(&"▲2 light".to_string()));
976        let _ = loud_idea;
977    }
978
979    #[test]
980    fn queue_caps_at_the_highest_scoring_rows() {
981        let mut s = setup();
982        let p = add_project(&mut s, "p", 3);
983        let tasks: Vec<i64> = (0..QUEUE_MAX_ROWS + 4)
984            .map(|i| {
985                let id = add_task(&mut s, p, &format!("t{i}"), Priority::P2);
986                // older tasks score higher via the age bonus, so ordering is
987                // deterministic: index 0 oldest, last youngest.
988                set_age_days(&mut s, id, (QUEUE_MAX_ROWS + 4 - i) as f64);
989                id
990            })
991            .collect();
992
993        let ids = task_ids(&default_queue(&s));
994        assert_eq!(ids.len(), QUEUE_MAX_ROWS);
995        assert_eq!(ids, tasks[..QUEUE_MAX_ROWS]);
996    }
997
998    #[test]
999    fn the_cap_drops_a_low_scoring_attention_item_regardless_of_state() {
1000        // The cap is uniform across state: a low-scoring question can fall
1001        // below it just like a ready task, once enough higher-scoring work
1002        // exists. Ten P0 ready tasks in a heavy project (score 40) fill the cap
1003        // and push a lone P3 question in a light project (1×(1+4) = 5) off.
1004        let mut s = setup();
1005        let heavy = add_project(&mut s, "heavy", 5);
1006        let light = add_project(&mut s, "light", 1);
1007        let loud: Vec<i64> = (0..QUEUE_MAX_ROWS)
1008            .map(|i| add_task(&mut s, heavy, &format!("loud {i}"), Priority::P0))
1009            .collect();
1010        let quiet_question = add_task(&mut s, light, "quiet question", Priority::P3);
1011        to_needs_input(&mut s, quiet_question);
1012
1013        let ids = task_ids(&default_queue(&s));
1014        assert_eq!(ids.len(), QUEUE_MAX_ROWS);
1015        assert!(!ids.contains(&quiet_question));
1016        for id in &loud {
1017            assert!(ids.contains(id));
1018        }
1019    }
1020
1021    #[test]
1022    fn state_bonus_lifts_a_question_over_an_equal_priority_review() {
1023        // Same weight and priority: the +4 needs-input bonus outscores the +2 review.
1024        let mut s = setup();
1025        let p = add_project(&mut s, "p", 3);
1026        let diff = add_task(&mut s, p, "diff", Priority::P1); // 3×(4+2) = 18
1027        to_review(&mut s, diff);
1028        let question = add_task(&mut s, p, "question", Priority::P1); // 3×(4+4) = 24
1029        to_needs_input(&mut s, question);
1030
1031        let ids = task_ids(&default_queue(&s));
1032        assert_eq!(ids, vec![question, diff]);
1033    }
1034
1035    #[test]
1036    fn a_stalled_task_scores_the_review_bonus_and_competes_in_the_queue() {
1037        // stalled earns +2, the same as review (§7).
1038        assert_eq!(state_bonus(TaskState::Stalled), 2.0);
1039        assert_eq!(
1040            score(3, Priority::P2, TaskState::Stalled, 0.0, 0).base,
1041            12.0
1042        );
1043
1044        // A stalled P2 (3×(2+2) = 12) ties a ready P1 (3×4 = 12) exactly; the
1045        // state precedence slots stalled after review, before ready.
1046        let mut s = setup();
1047        let p = add_project(&mut s, "p", 3);
1048        let ready = add_task(&mut s, p, "ready", Priority::P1);
1049        let stalled = add_task(&mut s, p, "stalled", Priority::P2);
1050        to_stalled(&mut s, stalled);
1051        s.conn
1052            .execute(
1053                "UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
1054                (ready, stalled),
1055            )
1056            .unwrap();
1057
1058        let ids = task_ids(&default_queue(&s));
1059        assert_eq!(ids, vec![stalled, ready]);
1060    }
1061
1062    #[test]
1063    fn focus_never_hands_out_a_stalled_task() {
1064        // `voro next` answers with fresh startable work only, so even a stalled
1065        // task that outscores every ready task stays out of focus() while
1066        // still leading the queue.
1067        let mut s = setup();
1068        let p = add_project(&mut s, "p", 3);
1069        let stalled = add_task(&mut s, p, "stalled", Priority::P0);
1070        to_stalled(&mut s, stalled);
1071        let ready = add_task(&mut s, p, "ready", Priority::P3);
1072
1073        assert_eq!(focus(&s.candidates().unwrap()).unwrap().task.id, ready);
1074        assert_eq!(task_ids(&default_queue(&s)), vec![stalled, ready]);
1075    }
1076
1077    #[test]
1078    fn waiting_is_excluded_from_the_queue_and_earns_no_bonus() {
1079        // `waiting` is out of the running like `parked` (DESIGN.md §6/§7): it
1080        // earns no state bonus and never surfaces in the queue or focus, only
1081        // in the state counts.
1082        assert_eq!(state_bonus(TaskState::Waiting), 0.0);
1083
1084        let mut s = setup();
1085        let p = add_project(&mut s, "p", 5);
1086        let waiting = add_task(&mut s, p, "handed off", Priority::P0);
1087        to_waiting(&mut s, waiting);
1088        let ready = add_task(&mut s, p, "startable", Priority::P3);
1089
1090        let candidates = s.candidates().unwrap();
1091        // even a P0 waiting task in a heavy project stays out of both views
1092        assert!(candidates.iter().all(|c| c.task.id != waiting));
1093        assert_eq!(task_ids(&default_queue(&s)), vec![ready]);
1094        assert_eq!(focus(&candidates).unwrap().task.id, ready);
1095
1096        // but it is felt in the state counts
1097        assert_eq!(s.state_counts().unwrap().waiting, 1);
1098    }
1099
1100    /// A proposal being refined is out of the triage queue for the duration
1101    /// (DESIGN.md §6): the operator cannot triage a body an agent is mid-way
1102    /// through rewriting, and the state — not a guard in the TUI — is what takes
1103    /// it out, so it is gone in every window at once.
1104    #[test]
1105    fn a_refining_proposal_leaves_the_queue_and_is_counted_apart() {
1106        let mut s = setup();
1107        let p = add_project(&mut s, "p", 5);
1108        let refining = add_proposed(&mut s, p, "being rewritten", Priority::P0);
1109        s.record_refine_launch(
1110            refining,
1111            "thin body",
1112            "claude",
1113            Some(1),
1114            LivenessSource::Pid,
1115            None,
1116        )
1117        .unwrap();
1118        let ready = add_task(&mut s, p, "startable", Priority::P3);
1119
1120        let candidates = s.candidates().unwrap();
1121        assert!(candidates.iter().all(|c| c.task.id != refining));
1122        assert_eq!(task_ids(&default_queue(&s)), vec![ready]);
1123
1124        let counts = s.state_counts().unwrap();
1125        assert_eq!(counts.refining, 1);
1126        assert_eq!(counts.proposed, 0);
1127
1128        // and it is back the moment the round concludes
1129        s.conclude_refine(refining, crate::RefineOutcome::Applied)
1130            .unwrap();
1131        assert_eq!(s.state_counts().unwrap().refining, 0);
1132        assert!(
1133            s.candidates()
1134                .unwrap()
1135                .iter()
1136                .any(|c| c.task.id == refining)
1137        );
1138    }
1139
1140    #[test]
1141    fn equal_raw_totals_are_split_by_what_the_row_costs() {
1142        // Contrived so the folded scores collide: needs-input 3×(1+4) = 15,
1143        // review 5×(1+2) = 15. Before pricing this was a genuine tie broken by
1144        // the state precedence (§6); now the divisors decide it outright —
1145        // 18.75 for the question against 10.71 for the diff — and the
1146        // precedence is left to rows that tie on the effective score too.
1147        let mut s = setup();
1148        let a = add_project(&mut s, "a", 3);
1149        let b = add_project(&mut s, "b", 5);
1150        let diff = add_task(&mut s, b, "diff", Priority::P3);
1151        to_review(&mut s, diff);
1152        let question = add_task(&mut s, a, "question", Priority::P3);
1153        to_needs_input(&mut s, question);
1154        s.conn
1155            .execute(
1156                "UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
1157                (diff, question),
1158            )
1159            .unwrap();
1160
1161        let candidates = s.candidates().unwrap();
1162        let total = |id| {
1163            candidates
1164                .iter()
1165                .find(|c| c.task.id == id)
1166                .unwrap()
1167                .score
1168                .total
1169        };
1170        assert_eq!(total(diff), total(question));
1171        assert_eq!(task_ids(&default_queue(&s)), vec![question, diff]);
1172    }
1173
1174    #[test]
1175    fn age_bonus_breaks_priority_ties_and_starvation() {
1176        let mut s = setup();
1177        let p = add_project(&mut s, "p", 3);
1178        let fresh = add_task(&mut s, p, "fresh", Priority::P2);
1179        let stale = add_task(&mut s, p, "stale", Priority::P2);
1180        set_age_days(&mut s, stale, 10.0);
1181
1182        let candidates = s.candidates().unwrap();
1183        let top = focus(&candidates).unwrap();
1184        assert_eq!(top.task.id, stale);
1185        assert!((top.score.age_bonus - 1.0).abs() < 0.01);
1186
1187        // but capped age can never fake a priority level
1188        set_age_days(&mut s, stale, 300.0);
1189        let higher = add_task(&mut s, p, "actually urgent", Priority::P1);
1190        let candidates = s.candidates().unwrap();
1191        assert_eq!(focus(&candidates).unwrap().task.id, higher);
1192        let _ = fresh;
1193    }
1194
1195    #[test]
1196    fn weight_zero_projects_are_hidden_everywhere() {
1197        let mut s = setup();
1198        let parked = add_project(&mut s, "parked", 0);
1199        let active = add_project(&mut s, "active", 1);
1200
1201        let hidden_q = add_task(&mut s, parked, "hidden question", Priority::P0);
1202        to_needs_input(&mut s, hidden_q);
1203        add_task(&mut s, parked, "hidden ready", Priority::P0);
1204        s.create_task(NewTask {
1205            project_id: parked,
1206            repo_id: None,
1207            title: "hidden proposed".into(),
1208            body: String::new(),
1209            priority: Priority::P2,
1210            state: TaskState::Proposed,
1211            agent: None,
1212            human: false,
1213            deep: false,
1214        })
1215        .unwrap();
1216        let visible = add_task(&mut s, active, "visible", Priority::P3);
1217
1218        let ids = task_ids(&default_queue(&s));
1219        assert_eq!(ids, vec![visible]);
1220        assert_eq!(focus(&s.candidates().unwrap()).unwrap().task.id, visible);
1221        assert_eq!(s.proposed_count().unwrap(), 0);
1222    }
1223
1224    #[test]
1225    fn archived_projects_are_hidden_from_queue_focus_and_counts() {
1226        // An archived project leaves the cockpit with all its tasks, whatever
1227        // their state (DESIGN.md §5); unarchiving restores the exact
1228        // pre-archive view, since nothing about the tasks was touched.
1229        let mut s = setup();
1230        let retiring = add_project(&mut s, "retiring", 5);
1231        let active = add_project(&mut s, "active", 1);
1232
1233        let question = add_task(&mut s, retiring, "question", Priority::P0);
1234        to_needs_input(&mut s, question);
1235        let ready = add_task(&mut s, retiring, "ready", Priority::P0);
1236        let idea = add_proposed(&mut s, retiring, "idea", Priority::P2);
1237        let done = add_task(&mut s, retiring, "done", Priority::P2);
1238        s.apply(done, crate::Action::Start).unwrap();
1239        s.apply(done, crate::Action::Complete(None)).unwrap();
1240        s.apply(done, crate::Action::Accept).unwrap();
1241        let visible = add_task(&mut s, active, "visible", Priority::P3);
1242
1243        let before_labels = labels(&default_queue(&s));
1244        assert_eq!(
1245            before_labels,
1246            vec![
1247                format!("#{question}"),
1248                format!("#{ready}"),
1249                "▲1 retiring".to_string(),
1250                format!("#{visible}"),
1251            ]
1252        );
1253        let _ = idea;
1254
1255        s.set_archived(retiring, true).unwrap();
1256        let ids = task_ids(&default_queue(&s));
1257        assert_eq!(ids, vec![visible]);
1258        assert_eq!(focus(&s.candidates().unwrap()).unwrap().task.id, visible);
1259        let counts = s.state_counts().unwrap();
1260        assert_eq!(counts.needs_input, 0);
1261        assert_eq!(counts.ready, 1);
1262        assert_eq!(counts.done, 0);
1263        assert_eq!(s.proposed_count().unwrap(), 0);
1264
1265        // Unarchive: everything is back exactly as before.
1266        s.set_archived(retiring, false).unwrap();
1267        let restored_labels = labels(&default_queue(&s));
1268        assert_eq!(restored_labels, before_labels);
1269        assert_eq!(s.state_counts().unwrap().done, 1);
1270    }
1271
1272    #[test]
1273    fn state_counts_group_by_state_and_hide_parked_projects() {
1274        let mut s = setup();
1275        let active = add_project(&mut s, "active", 3);
1276        let parked = add_project(&mut s, "parked", 0);
1277
1278        add_task(&mut s, active, "r1", Priority::P2);
1279        add_task(&mut s, active, "r2", Priority::P2);
1280        s.create_task(NewTask {
1281            project_id: active,
1282            repo_id: None,
1283            title: "idea".into(),
1284            body: String::new(),
1285            priority: Priority::P2,
1286            state: TaskState::Proposed,
1287            agent: None,
1288            human: false,
1289            deep: false,
1290        })
1291        .unwrap();
1292        let question = add_task(&mut s, active, "blocked on me", Priority::P2);
1293        to_needs_input(&mut s, question);
1294        let reviewed = add_task(&mut s, active, "in review", Priority::P2);
1295        s.apply(reviewed, crate::Action::Start).unwrap();
1296        s.apply(reviewed, crate::Action::Complete(None)).unwrap();
1297        let stalled = add_task(&mut s, active, "died mid-run", Priority::P2);
1298        to_stalled(&mut s, stalled);
1299
1300        // Everything in a parked (weight-0) project stays out of the tally.
1301        add_task(&mut s, parked, "hidden ready", Priority::P2);
1302        s.create_task(NewTask {
1303            project_id: parked,
1304            repo_id: None,
1305            title: "hidden idea".into(),
1306            body: String::new(),
1307            priority: Priority::P2,
1308            state: TaskState::Proposed,
1309            agent: None,
1310            human: false,
1311            deep: false,
1312        })
1313        .unwrap();
1314
1315        let c = s.state_counts().unwrap();
1316        assert_eq!(c.ready, 2);
1317        assert_eq!(c.proposed, 1);
1318        assert_eq!(c.needs_input, 1);
1319        assert_eq!(c.review, 1);
1320        assert_eq!(c.stalled, 1);
1321        assert_eq!(c.running, 0);
1322        assert_eq!(c.done, 0);
1323        // proposed_count is the same guard-rail number the counts expose.
1324        assert_eq!(s.proposed_count().unwrap(), 1);
1325    }
1326
1327    #[test]
1328    fn tasks_with_open_blockers_never_reach_the_queue() {
1329        let mut s = setup();
1330        let p = add_project(&mut s, "p", 3);
1331        let blocker = add_task(&mut s, p, "blocker", Priority::P2);
1332        let blocked = add_task(&mut s, p, "blocked", Priority::P0);
1333        s.add_dep(blocked, blocker, crate::DepKind::Blocks).unwrap();
1334
1335        // the high-priority blocked task is out of the running until its
1336        // blocker closes — neither view offers it
1337        let ids = task_ids(&default_queue(&s));
1338        assert_eq!(ids, vec![blocker]);
1339        assert_eq!(focus(&s.candidates().unwrap()).unwrap().task.id, blocker);
1340
1341        // once the blocker closes it surfaces, and now outranks it
1342        s.apply(blocker, crate::Action::Start).unwrap();
1343        s.apply(blocker, crate::Action::Complete(None)).unwrap();
1344        s.apply(blocker, crate::Action::Accept).unwrap();
1345        let candidates = s.candidates().unwrap();
1346        assert_eq!(focus(&candidates).unwrap().task.id, blocked);
1347    }
1348
1349    #[test]
1350    fn only_open_blocks_dependents_count_toward_the_unblock_bonus() {
1351        let mut s = setup();
1352        let p = add_project(&mut s, "p", 2);
1353        let blocker = add_task(&mut s, p, "blocker", Priority::P2);
1354
1355        // nothing waits on it yet
1356        let alone = s.explain(blocker).unwrap();
1357        assert_eq!(alone.open_dependents, 0);
1358        assert_eq!(alone.unblock_bonus, 0.0);
1359
1360        // an open task parked behind it counts
1361        let blocked = add_task(&mut s, p, "blocked", Priority::P2);
1362        s.add_dep(blocked, blocker, crate::DepKind::Blocks).unwrap();
1363        assert_eq!(s.explain(blocker).unwrap().open_dependents, 1);
1364
1365        // a closed dependent does not, whether accepted or rejected
1366        let finished = add_task(&mut s, p, "finished", Priority::P2);
1367        s.apply(finished, crate::Action::Start).unwrap();
1368        s.apply(finished, crate::Action::Complete(None)).unwrap();
1369        s.apply(finished, crate::Action::Accept).unwrap();
1370        s.add_dep(finished, blocker, crate::DepKind::Blocks)
1371            .unwrap();
1372        let idea = add_proposed(&mut s, p, "bad idea", Priority::P2);
1373        s.apply(idea, crate::Action::Triage(crate::Triage::Reject))
1374            .unwrap();
1375        s.add_dep(idea, blocker, crate::DepKind::Blocks).unwrap();
1376        assert_eq!(s.explain(blocker).unwrap().open_dependents, 1);
1377
1378        // nor does an edge of any other kind
1379        for kind in [
1380            crate::DepKind::DiscoveredFrom,
1381            crate::DepKind::Parent,
1382            crate::DepKind::Related,
1383        ] {
1384            let other = add_task(&mut s, p, "adjacent work", Priority::P2);
1385            s.add_dep(other, blocker, kind).unwrap();
1386        }
1387        assert_eq!(s.explain(blocker).unwrap().open_dependents, 1);
1388
1389        // the queue's own query agrees with the single-task decomposition
1390        let candidates = s.candidates().unwrap();
1391        let scored = candidates.iter().find(|c| c.task.id == blocker).unwrap();
1392        assert_eq!(scored.score.open_dependents, 1);
1393        assert_eq!(scored.score.base, 6.0); // 2×(2+1)
1394    }
1395
1396    #[test]
1397    fn blocking_open_work_lifts_a_task_over_an_identical_one() {
1398        let mut s = setup();
1399        let p = add_project(&mut s, "p", 3);
1400        let plain = add_task(&mut s, p, "plain", Priority::P2); // 3×2 = 6
1401        let blocking = add_task(&mut s, p, "blocking", Priority::P2); // 3×(2+1) = 9
1402        let blocked = add_task(&mut s, p, "blocked", Priority::P2);
1403        s.add_dep(blocked, blocking, crate::DepKind::Blocks)
1404            .unwrap();
1405        // pin the ages equal so only the unblock bonus separates them
1406        s.conn
1407            .execute(
1408                "UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
1409                (plain, blocking),
1410            )
1411            .unwrap();
1412
1413        let candidates = s.candidates().unwrap();
1414        // the blocked task is parked behind its blocker, so only the two
1415        // otherwise-identical tasks compete — and the one holding work up wins
1416        assert_eq!(task_ids(&default_queue(&s)), vec![blocking, plain]);
1417        assert_eq!(focus(&candidates).unwrap().task.id, blocking);
1418
1419        // a second and third dependent grow the bonus to the cap, no further
1420        for i in 0..2 {
1421            let more = add_task(&mut s, p, &format!("also blocked {i}"), Priority::P2);
1422            s.add_dep(more, blocking, crate::DepKind::Blocks).unwrap();
1423        }
1424        let candidates = s.candidates().unwrap();
1425        let scored = candidates.iter().find(|c| c.task.id == blocking).unwrap();
1426        assert_eq!(scored.score.open_dependents, 3);
1427        assert_eq!(scored.score.unblock_bonus, 2.0);
1428        assert_eq!(scored.score.base, 12.0); // 3×(2+2)
1429
1430        // and even at the cap it never fakes a priority level: a fresh P0
1431        // blocking nothing (3×8 = 24) still walks away with it
1432        let urgent = add_task(&mut s, p, "urgent", Priority::P0);
1433        let candidates = s.candidates().unwrap();
1434        assert_eq!(focus(&candidates).unwrap().task.id, urgent);
1435    }
1436
1437    #[test]
1438    fn deterministic_tail_ordering() {
1439        let mut s = setup();
1440        let p = add_project(&mut s, "p", 3);
1441        let first = add_task(&mut s, p, "first", Priority::P2);
1442        let second = add_task(&mut s, p, "second", Priority::P2);
1443
1444        let candidates = s.candidates().unwrap();
1445        // identical score, state, priority, state_since → id ascending
1446        assert_eq!(focus(&candidates).unwrap().task.id, first);
1447        let _ = second;
1448    }
1449}