1use crate::error::Result;
6use crate::model::{Priority, Task, TaskState};
7use crate::store::{Store, task_from_row};
8
9#[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 pub state_bonus: f64,
18 pub base: f64,
20 pub age_days: f64,
21 pub age_bonus: f64,
23 pub total: f64,
24}
25
26pub 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#[derive(Debug, Clone)]
58pub struct Candidate {
59 pub task: Task,
60 pub project_name: String,
61 pub score: ScoreBreakdown,
62}
63
64pub const QUEUE_MAX_ROWS: usize = 10;
69
70pub 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
81pub 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
91fn 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 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 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 pub fn proposed_count(&self) -> Result<i64> {
161 Ok(self.state_counts()?.proposed)
162 }
163
164 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#[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 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 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 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 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); to_needs_input(&mut s, question);
362 let diff = add_task(&mut s, a, "diff", Priority::P0); to_review(&mut s, diff);
364 let small = add_task(&mut s, b, "small question", Priority::P3); to_needs_input(&mut s, small);
366 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();
370 let ids: Vec<i64> = queue(&candidates).iter().map(|c| c.task.id).collect();
371 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 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 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 let mut s = setup();
423 let p = add_project(&mut s, "p", 3);
424 let diff = add_task(&mut s, p, "diff", Priority::P1); to_review(&mut s, diff);
426 let question = add_task(&mut s, p, "question", Priority::P1); 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 assert_eq!(state_bonus(TaskState::Stalled), 2.0);
438 assert_eq!(score(3, Priority::P2, TaskState::Stalled, 0.0).base, 12.0);
439
440 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 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 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 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 assert_eq!(s.state_counts().unwrap().waiting, 1);
498 }
499
500 #[test]
501 fn state_precedence_still_breaks_genuinely_equal_totals() {
502 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 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 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 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 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 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 assert_eq!(focus(&candidates).unwrap().task.id, first);
666 let _ = second;
667 }
668}