use crate::error::Result;
use crate::model::{Priority, Task, TaskState};
use crate::store::{Store, task_from_row};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScoreBreakdown {
pub weight: i64,
pub priority: Priority,
pub priority_value: f64,
pub state: TaskState,
pub state_bonus: f64,
pub base: f64,
pub age_days: f64,
pub age_bonus: f64,
pub total: f64,
}
pub fn state_bonus(state: TaskState) -> f64 {
match state {
TaskState::NeedsInput => 4.0,
TaskState::Review | TaskState::Stalled => 2.0,
_ => 0.0,
}
}
pub fn score(weight: i64, priority: Priority, state: TaskState, age_days: f64) -> ScoreBreakdown {
let priority_value = priority.value();
let state_bonus = state_bonus(state);
let base = weight as f64 * (priority_value + state_bonus);
let age_bonus = (0.1 * age_days).min(2.0);
ScoreBreakdown {
weight,
priority,
priority_value,
state,
state_bonus,
base,
age_days,
age_bonus,
total: base + age_bonus,
}
}
#[derive(Debug, Clone)]
pub struct Candidate {
pub task: Task,
pub project_name: String,
pub score: ScoreBreakdown,
}
pub const QUEUE_MAX_ROWS: usize = 10;
pub fn queue(candidates: &[Candidate]) -> Vec<&Candidate> {
let mut items: Vec<&Candidate> = candidates.iter().collect();
items.sort_by(|a, b| rank(a, b));
items.truncate(QUEUE_MAX_ROWS);
items
}
pub fn focus(candidates: &[Candidate]) -> Option<&Candidate> {
candidates
.iter()
.filter(|c| c.task.state == TaskState::Ready)
.min_by(|a, b| rank(a, b))
}
fn rank(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
b.score
.total
.total_cmp(&a.score.total)
.then_with(|| state_rank(a.task.state).cmp(&state_rank(b.task.state)))
.then_with(|| a.task.priority.cmp(&b.task.priority))
.then_with(|| a.task.state_since.cmp(&b.task.state_since))
.then_with(|| a.task.id.cmp(&b.task.id))
}
fn state_rank(state: TaskState) -> u8 {
match state {
TaskState::NeedsInput => 0,
TaskState::Review => 1,
TaskState::Stalled => 2,
TaskState::Ready => 3,
_ => 4,
}
}
impl Store {
pub fn candidates(&self) -> Result<Vec<Candidate>> {
let mut stmt = self.conn.prepare(
"SELECT t.id, t.project_id, t.title, t.body, t.priority, t.state, t.agent,
t.question, t.pr_url, t.branch, t.state_since, t.created_at, t.closed_at,
t.human, p.name, p.weight,
julianday('now') - julianday(t.state_since)
FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE p.weight > 0
AND t.state IN ('ready','needs-input','review','stalled','proposed')",
)?;
let rows = stmt.query_map([], |row| {
let task = task_from_row(row)?;
let project_name: String = row.get(14)?;
let weight: i64 = row.get(15)?;
let age_days: f64 = row.get(16)?;
let score = score(weight, task.priority, task.state, age_days);
Ok(Candidate {
task,
project_name,
score,
})
})?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn explain(&self, task_id: i64) -> Result<ScoreBreakdown> {
let (weight, priority, state, age_days): (i64, Priority, TaskState, f64) =
self.conn.query_row(
"SELECT p.weight, t.priority, t.state,
julianday('now') - julianday(t.state_since)
FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE t.id = ?1",
[task_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)?;
Ok(score(weight, priority, state, age_days))
}
pub fn proposed_count(&self) -> Result<i64> {
Ok(self.state_counts()?.proposed)
}
pub fn state_counts(&self) -> Result<StateCounts> {
let mut stmt = self.conn.prepare(
"SELECT t.state, COUNT(*) FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE p.weight > 0 GROUP BY t.state",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, TaskState>(0)?, r.get::<_, i64>(1)?)))?;
let mut counts = StateCounts::default();
for row in rows {
let (state, n) = row?;
match state {
TaskState::Proposed => counts.proposed = n,
TaskState::Ready => counts.ready = n,
TaskState::Running => counts.running = n,
TaskState::NeedsInput => counts.needs_input = n,
TaskState::Review => counts.review = n,
TaskState::Waiting => counts.waiting = n,
TaskState::Stalled => counts.stalled = n,
TaskState::Done => counts.done = n,
TaskState::Parked | TaskState::Rejected => {}
}
}
Ok(counts)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StateCounts {
pub proposed: i64,
pub ready: i64,
pub running: i64,
pub needs_input: i64,
pub review: i64,
pub waiting: i64,
pub stalled: i64,
pub done: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::TaskState;
use crate::store::NewTask;
#[test]
fn worked_example_from_design_doc() {
let p0_low_weight = score(2, Priority::P0, TaskState::Ready, 0.0);
let p2_high_weight = score(5, Priority::P2, TaskState::Ready, 0.0);
assert_eq!(p0_low_weight.total, 16.0);
assert_eq!(p2_high_weight.total, 10.0);
assert!(p0_low_weight.total > p2_high_weight.total);
}
#[test]
fn priority_values_are_geometric() {
assert_eq!(score(1, Priority::P0, TaskState::Ready, 0.0).total, 8.0);
assert_eq!(score(1, Priority::P1, TaskState::Ready, 0.0).total, 4.0);
assert_eq!(score(1, Priority::P2, TaskState::Ready, 0.0).total, 2.0);
assert_eq!(score(1, Priority::P3, TaskState::Ready, 0.0).total, 1.0);
}
#[test]
fn age_bonus_grows_then_caps_at_two() {
assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0).age_bonus, 0.0);
assert_eq!(score(3, Priority::P2, TaskState::Ready, 5.0).age_bonus, 0.5);
assert_eq!(
score(3, Priority::P2, TaskState::Ready, 20.0).age_bonus,
2.0
);
assert_eq!(
score(3, Priority::P2, TaskState::Ready, 365.0).age_bonus,
2.0
);
assert_eq!(score(3, Priority::P2, TaskState::Ready, 365.0).total, 8.0);
}
#[test]
fn decomposition_terms_sum_to_total() {
let s = score(4, Priority::P1, TaskState::Ready, 7.3);
assert_eq!(s.base, 16.0);
assert_eq!(s.total, s.base + s.age_bonus);
}
#[test]
fn state_bonus_folds_into_the_priority_term() {
assert_eq!(state_bonus(TaskState::NeedsInput), 4.0);
assert_eq!(state_bonus(TaskState::Review), 2.0);
assert_eq!(state_bonus(TaskState::Ready), 0.0);
assert_eq!(state_bonus(TaskState::Proposed), 0.0);
assert_eq!(
score(3, Priority::P2, TaskState::NeedsInput, 0.0).base,
18.0
);
assert_eq!(score(3, Priority::P2, TaskState::Review, 0.0).base, 12.0);
assert_eq!(score(3, Priority::P2, TaskState::Ready, 0.0).base, 6.0);
}
fn setup() -> Store {
Store::open_in_memory().unwrap()
}
fn add_project(s: &mut Store, name: &str, weight: i64) -> i64 {
let p = s.create_project(name, "/tmp").unwrap();
s.set_weight(p.id, weight).unwrap();
p.id
}
fn add_task(s: &mut Store, project_id: i64, title: &str, priority: Priority) -> i64 {
s.create_task(NewTask {
project_id,
title: title.into(),
body: String::new(),
priority,
state: TaskState::Ready,
agent: None,
human: false,
})
.unwrap()
.id
}
fn to_needs_input(s: &mut Store, id: i64) {
s.apply(id, crate::Action::Start).unwrap();
s.apply(id, crate::Action::Ask("?".into())).unwrap();
}
fn to_review(s: &mut Store, id: i64) {
s.apply(id, crate::Action::Start).unwrap();
s.apply(id, crate::Action::Complete(None)).unwrap();
}
fn to_stalled(s: &mut Store, id: i64) {
let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap();
s.reconcile_session(session.id, false, false).unwrap();
}
fn to_waiting(s: &mut Store, id: i64) {
s.apply(id, crate::Action::Start).unwrap();
s.apply(id, crate::Action::Complete(None)).unwrap();
s.apply(id, crate::Action::HandOff).unwrap();
}
fn add_proposed(s: &mut Store, project_id: i64, title: &str, priority: Priority) -> i64 {
s.create_task(NewTask {
project_id,
title: title.into(),
body: String::new(),
priority,
state: TaskState::Proposed,
agent: None,
human: false,
})
.unwrap()
.id
}
fn set_age_days(s: &mut Store, id: i64, days: f64) {
s.conn
.execute(
"UPDATE tasks SET state_since = datetime('now', ?1 || ' days') WHERE id = ?2",
(format!("-{days}"), id),
)
.unwrap();
}
#[test]
fn focus_picks_the_worked_example_winner() {
let mut s = setup();
let side = add_project(&mut s, "side-project", 2);
let main = add_project(&mut s, "main-project", 5);
let p0 = add_task(&mut s, side, "urgent fix", Priority::P0);
add_task(&mut s, main, "nice to have", Priority::P2);
let candidates = s.candidates().unwrap();
let top = focus(&candidates).unwrap();
assert_eq!(top.task.id, p0);
assert_eq!(top.score.base, 16.0);
}
#[test]
fn queue_interleaves_every_actionable_state_by_score() {
let mut s = setup();
let a = add_project(&mut s, "a", 3);
let b = add_project(&mut s, "b", 1);
let question = add_task(&mut s, a, "question", Priority::P2); to_needs_input(&mut s, question);
let diff = add_task(&mut s, a, "diff", Priority::P0); to_review(&mut s, diff);
let small = add_task(&mut s, b, "small question", Priority::P3); to_needs_input(&mut s, small);
let ready = add_task(&mut s, a, "ready task", Priority::P1); let proposed = add_proposed(&mut s, a, "proposal", Priority::P2);
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![diff, question, ready, proposed, small]);
}
#[test]
fn queue_caps_at_the_highest_scoring_rows() {
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let tasks: Vec<i64> = (0..QUEUE_MAX_ROWS + 4)
.map(|i| {
let id = add_task(&mut s, p, &format!("t{i}"), Priority::P2);
set_age_days(&mut s, id, (QUEUE_MAX_ROWS + 4 - i) as f64);
id
})
.collect();
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids.len(), QUEUE_MAX_ROWS);
assert_eq!(ids, tasks[..QUEUE_MAX_ROWS]);
}
#[test]
fn the_cap_drops_a_low_scoring_attention_item_regardless_of_state() {
let mut s = setup();
let heavy = add_project(&mut s, "heavy", 5);
let light = add_project(&mut s, "light", 1);
let loud: Vec<i64> = (0..QUEUE_MAX_ROWS)
.map(|i| add_task(&mut s, heavy, &format!("loud {i}"), Priority::P0))
.collect();
let quiet_question = add_task(&mut s, light, "quiet question", Priority::P3);
to_needs_input(&mut s, quiet_question);
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids.len(), QUEUE_MAX_ROWS);
assert!(!ids.contains(&quiet_question));
for id in &loud {
assert!(ids.contains(id));
}
}
#[test]
fn state_bonus_lifts_a_question_over_an_equal_priority_review() {
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let diff = add_task(&mut s, p, "diff", Priority::P1); to_review(&mut s, diff);
let question = add_task(&mut s, p, "question", Priority::P1); to_needs_input(&mut s, question);
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![question, diff]);
}
#[test]
fn a_stalled_task_scores_the_review_bonus_and_competes_in_the_queue() {
assert_eq!(state_bonus(TaskState::Stalled), 2.0);
assert_eq!(score(3, Priority::P2, TaskState::Stalled, 0.0).base, 12.0);
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let ready = add_task(&mut s, p, "ready", Priority::P1);
let stalled = add_task(&mut s, p, "stalled", Priority::P2);
to_stalled(&mut s, stalled);
s.conn
.execute(
"UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
(ready, stalled),
)
.unwrap();
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![stalled, ready]);
}
#[test]
fn focus_never_hands_out_a_stalled_task() {
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let stalled = add_task(&mut s, p, "stalled", Priority::P0);
to_stalled(&mut s, stalled);
let ready = add_task(&mut s, p, "ready", Priority::P3);
let candidates = s.candidates().unwrap();
assert_eq!(focus(&candidates).unwrap().task.id, ready);
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![stalled, ready]);
}
#[test]
fn waiting_is_excluded_from_the_queue_and_earns_no_bonus() {
assert_eq!(state_bonus(TaskState::Waiting), 0.0);
let mut s = setup();
let p = add_project(&mut s, "p", 5);
let waiting = add_task(&mut s, p, "handed off", Priority::P0);
to_waiting(&mut s, waiting);
let ready = add_task(&mut s, p, "startable", Priority::P3);
let candidates = s.candidates().unwrap();
assert!(candidates.iter().all(|c| c.task.id != waiting));
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![ready]);
assert_eq!(focus(&candidates).unwrap().task.id, ready);
assert_eq!(s.state_counts().unwrap().waiting, 1);
}
#[test]
fn state_precedence_still_breaks_genuinely_equal_totals() {
let mut s = setup();
let a = add_project(&mut s, "a", 3);
let b = add_project(&mut s, "b", 5);
let diff = add_task(&mut s, b, "diff", Priority::P3);
to_review(&mut s, diff);
let question = add_task(&mut s, a, "question", Priority::P3);
to_needs_input(&mut s, question);
s.conn
.execute(
"UPDATE tasks SET state_since = '2020-01-01 00:00:00' WHERE id IN (?1, ?2)",
(diff, question),
)
.unwrap();
let candidates = s.candidates().unwrap();
let total = |id| {
candidates
.iter()
.find(|c| c.task.id == id)
.unwrap()
.score
.total
};
assert_eq!(total(diff), total(question));
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![question, diff]);
}
#[test]
fn age_bonus_breaks_priority_ties_and_starvation() {
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let fresh = add_task(&mut s, p, "fresh", Priority::P2);
let stale = add_task(&mut s, p, "stale", Priority::P2);
set_age_days(&mut s, stale, 10.0);
let candidates = s.candidates().unwrap();
let top = focus(&candidates).unwrap();
assert_eq!(top.task.id, stale);
assert!((top.score.age_bonus - 1.0).abs() < 0.01);
set_age_days(&mut s, stale, 300.0);
let higher = add_task(&mut s, p, "actually urgent", Priority::P1);
let candidates = s.candidates().unwrap();
assert_eq!(focus(&candidates).unwrap().task.id, higher);
let _ = fresh;
}
#[test]
fn weight_zero_projects_are_hidden_everywhere() {
let mut s = setup();
let parked = add_project(&mut s, "parked", 0);
let active = add_project(&mut s, "active", 1);
let hidden_q = add_task(&mut s, parked, "hidden question", Priority::P0);
to_needs_input(&mut s, hidden_q);
add_task(&mut s, parked, "hidden ready", Priority::P0);
s.create_task(NewTask {
project_id: parked,
title: "hidden proposed".into(),
body: String::new(),
priority: Priority::P2,
state: TaskState::Proposed,
agent: None,
human: false,
})
.unwrap();
let visible = add_task(&mut s, active, "visible", Priority::P3);
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![visible]);
assert_eq!(focus(&candidates).unwrap().task.id, visible);
assert_eq!(s.proposed_count().unwrap(), 0);
}
#[test]
fn state_counts_group_by_state_and_hide_parked_projects() {
let mut s = setup();
let active = add_project(&mut s, "active", 3);
let parked = add_project(&mut s, "parked", 0);
add_task(&mut s, active, "r1", Priority::P2);
add_task(&mut s, active, "r2", Priority::P2);
s.create_task(NewTask {
project_id: active,
title: "idea".into(),
body: String::new(),
priority: Priority::P2,
state: TaskState::Proposed,
agent: None,
human: false,
})
.unwrap();
let question = add_task(&mut s, active, "blocked on me", Priority::P2);
to_needs_input(&mut s, question);
let reviewed = add_task(&mut s, active, "in review", Priority::P2);
s.apply(reviewed, crate::Action::Start).unwrap();
s.apply(reviewed, crate::Action::Complete(None)).unwrap();
let stalled = add_task(&mut s, active, "died mid-run", Priority::P2);
to_stalled(&mut s, stalled);
add_task(&mut s, parked, "hidden ready", Priority::P2);
s.create_task(NewTask {
project_id: parked,
title: "hidden idea".into(),
body: String::new(),
priority: Priority::P2,
state: TaskState::Proposed,
agent: None,
human: false,
})
.unwrap();
let c = s.state_counts().unwrap();
assert_eq!(c.ready, 2);
assert_eq!(c.proposed, 1);
assert_eq!(c.needs_input, 1);
assert_eq!(c.review, 1);
assert_eq!(c.stalled, 1);
assert_eq!(c.running, 0);
assert_eq!(c.done, 0);
assert_eq!(s.proposed_count().unwrap(), 1);
}
#[test]
fn tasks_with_open_blockers_never_reach_the_queue() {
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let blocker = add_task(&mut s, p, "blocker", Priority::P2);
let blocked = add_task(&mut s, p, "blocked", Priority::P0);
s.add_dep(blocked, blocker, crate::DepKind::Blocks).unwrap();
let candidates = s.candidates().unwrap();
let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
assert_eq!(ids, vec![blocker]);
assert_eq!(focus(&candidates).unwrap().task.id, blocker);
s.apply(blocker, crate::Action::Start).unwrap();
s.apply(blocker, crate::Action::Complete(None)).unwrap();
s.apply(blocker, crate::Action::Accept).unwrap();
let candidates = s.candidates().unwrap();
assert_eq!(focus(&candidates).unwrap().task.id, blocked);
}
#[test]
fn deterministic_tail_ordering() {
let mut s = setup();
let p = add_project(&mut s, "p", 3);
let first = add_task(&mut s, p, "first", Priority::P2);
let second = add_task(&mut s, p, "second", Priority::P2);
let candidates = s.candidates().unwrap();
assert_eq!(focus(&candidates).unwrap().task.id, first);
let _ = second;
}
}