1use crate::error::Result;
6use crate::model::{NextAction, 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 open_dependents: i64,
20 pub unblock_bonus: f64,
22 pub base: f64,
24 pub age_days: f64,
25 pub age_bonus: f64,
27 pub total: f64,
28}
29
30pub 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
42pub 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#[derive(Debug, Clone)]
78pub struct Candidate {
79 pub task: Task,
80 pub project_name: String,
81 pub score: ScoreBreakdown,
82}
83
84pub const QUEUE_MAX_ROWS: usize = 10;
89
90pub const DEFAULT_MAX_RUNNING: i64 = 5;
93
94#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct AttentionCosts {
100 pub answer: f64,
102 pub triage: f64,
104 pub dispatch: f64,
107 pub review: f64,
109 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 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
145fn opens_a_session(action: NextAction) -> bool {
148 matches!(action, NextAction::Dispatch | NextAction::Redispatch)
149}
150
151#[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#[allow(clippy::large_enum_variant)]
170#[derive(Debug, Clone)]
171pub enum QueueRow {
172 Action(ActionRow),
174 Digest(DigestRow),
176}
177
178#[derive(Debug, Clone)]
180pub struct ActionRow {
181 pub candidate: Candidate,
182 pub action: NextAction,
183 pub cost: f64,
184 pub effective: f64,
186}
187
188#[derive(Debug, Clone)]
192pub struct DigestRow {
193 pub project_name: String,
194 pub tasks: Vec<ActionRow>,
196 pub effective: f64,
197}
198
199#[derive(Debug, Clone)]
202pub struct Queue {
203 pub rows: Vec<QueueRow>,
204 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 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
227pub 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
265fn 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
297fn 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#[derive(Debug, Clone, Copy, PartialEq)]
312pub struct EffectiveScore {
313 pub action: NextAction,
314 pub cost: f64,
315 pub effective: f64,
316}
317
318pub 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
330pub 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
340fn 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 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 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 pub fn proposed_count(&self) -> Result<i64> {
426 Ok(self.state_counts()?.proposed)
427 }
428
429 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
461pub struct StateCounts {
462 pub proposed: i64,
463 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 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 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 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 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 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 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 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 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 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); to_needs_input(&mut s, question);
726 let diff = add_task(&mut s, a, "diff", Priority::P0); to_review(&mut s, diff);
728 let small = add_task(&mut s, b, "small question", Priority::P3); to_needs_input(&mut s, small);
730 let ready = add_task(&mut s, a, "ready task", Priority::P1); add_proposed(&mut s, a, "proposal", Priority::P2); let rows = labels(&default_queue(&s));
734 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 #[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 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 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); to_review(&mut s, diff);
773 add_proposed(&mut s, p, "close idea", Priority::P2); add_proposed(&mut s, other, "distant idea", Priority::P3); 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 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 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 assert_eq!(
816 labels(&default_queue(&s)),
817 vec![format!("#{dispatchable}"), format!("#{by_hand}"),]
818 );
819 }
820
821 #[test]
824 fn the_wip_gate_suppresses_dispatch_rows_only_at_the_cap() {
825 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 let below = at(1, 2);
849 assert_eq!(below.at_capacity, None);
850 assert_eq!(task_ids(&below), vec![stalled, ready, question]);
851
852 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 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 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 #[test]
925 fn proposals_collapse_into_one_digest_scored_as_its_best_child() {
926 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); 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 assert!(
945 (digest.effective - 30.0).abs() < 0.1,
946 "{}",
947 digest.effective
948 );
949 assert_eq!(digest.tasks[0].candidate.task.id, best);
952 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); }
964 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 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 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 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 let mut s = setup();
1025 let p = add_project(&mut s, "p", 3);
1026 let diff = add_task(&mut s, p, "diff", Priority::P1); to_review(&mut s, diff);
1028 let question = add_task(&mut s, p, "question", Priority::P1); 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 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 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 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 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 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 assert_eq!(s.state_counts().unwrap().waiting, 1);
1098 }
1099
1100 #[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 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 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 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 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 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 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 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 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 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 let alone = s.explain(blocker).unwrap();
1357 assert_eq!(alone.open_dependents, 0);
1358 assert_eq!(alone.unblock_bonus, 0.0);
1359
1360 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 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 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 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); }
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); let blocking = add_task(&mut s, p, "blocking", Priority::P2); let blocked = add_task(&mut s, p, "blocked", Priority::P2);
1403 s.add_dep(blocked, blocking, crate::DepKind::Blocks)
1404 .unwrap();
1405 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 assert_eq!(task_ids(&default_queue(&s)), vec![blocking, plain]);
1417 assert_eq!(focus(&candidates).unwrap().task.id, blocking);
1418
1419 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); 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 assert_eq!(focus(&candidates).unwrap().task.id, first);
1447 let _ = second;
1448 }
1449}