1use std::collections::{HashMap, HashSet, VecDeque};
8
9use rusqlite::{Connection, params};
10
11use crate::error::{Error, Result};
12use crate::model::{LivenessSource, RefineOutcome, Session, SessionOutcome, Task, TaskState};
13use crate::store::{
14 Store, close_open_session, get_open_session, get_session, get_task, insert_session, log_event,
15 set_session_outcome,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Triage {
21 Parked,
22 Ready,
23 Reject,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Action {
28 Triage(Triage),
30 Refine(String),
34 ConcludeRefine(RefineOutcome),
36 Start,
39 Ask(String),
41 Resume,
45 Complete(Option<String>),
49 HandOff,
52 Reclaim,
55 Accept,
57 RejectWork(String),
60 Abort,
62 Park,
64 Unpark,
66 Abandon,
68}
69
70impl Action {
71 fn name(&self) -> &'static str {
72 match self {
73 Action::Triage(_) => "triage",
74 Action::Refine(_) => "refine",
75 Action::ConcludeRefine(_) => "conclude the refine of",
76 Action::Start => "start",
77 Action::Ask(_) => "ask",
78 Action::Resume => "resume",
79 Action::Complete(_) => "complete",
80 Action::HandOff => "hand off",
81 Action::Reclaim => "reclaim",
82 Action::Accept => "accept",
83 Action::RejectWork(_) => "reject work on",
84 Action::Abort => "abort",
85 Action::Park => "park",
86 Action::Unpark => "unpark",
87 Action::Abandon => "abandon",
88 }
89 }
90
91 pub fn stops_session(&self) -> bool {
104 matches!(self, Action::Accept | Action::Abort | Action::Abandon)
105 }
106}
107
108impl Store {
109 pub fn legal_actions(state: TaskState, human: bool) -> Vec<Action> {
116 use TaskState::*;
117 match state {
118 Proposed => vec![
119 Action::Triage(Triage::Ready),
120 Action::Triage(Triage::Parked),
121 Action::Triage(Triage::Reject),
122 ],
123 Refining => vec![Action::ConcludeRefine(RefineOutcome::Cancelled)],
127 Parked => vec![Action::Unpark, Action::Abandon],
128 Ready => vec![Action::Start, Action::Park, Action::Abandon],
129 Running if human => vec![Action::Complete(None), Action::Abort],
130 Running => vec![
131 Action::Ask(String::new()),
132 Action::Complete(None),
133 Action::Abort,
134 ],
135 NeedsInput => vec![Action::Resume, Action::Abandon],
136 Review => vec![
137 Action::Accept,
138 Action::RejectWork(String::new()),
139 Action::HandOff,
140 Action::Abandon,
141 ],
142 Waiting => vec![
143 Action::Accept,
144 Action::RejectWork(String::new()),
145 Action::Reclaim,
146 Action::Abandon,
147 ],
148 Stalled => vec![
149 Action::Start,
150 Action::Complete(None),
151 Action::Park,
152 Action::Abandon,
153 ],
154 Done | Rejected => vec![],
155 }
156 }
157
158 pub fn apply(&mut self, task_id: i64, action: Action) -> Result<Task> {
159 self.apply_closing(task_id, action).map(|(task, _)| task)
160 }
161
162 pub fn apply_closing(
175 &mut self,
176 task_id: i64,
177 action: Action,
178 ) -> Result<(Task, Option<Session>)> {
179 let tx = self.conn.transaction()?;
180 let closing = if action.stops_session() {
184 get_open_session(&tx, task_id)?.map(|s| s.id)
185 } else {
186 None
187 };
188 apply_action(&tx, task_id, action)?;
189 tx.commit()?;
190 Ok((
191 self.task(task_id)?,
192 closing.map(|id| self.session(id)).transpose()?,
193 ))
194 }
195
196 pub fn record_dispatch(
203 &mut self,
204 task_id: i64,
205 agent: &str,
206 pid: Option<i64>,
207 liveness_source: LivenessSource,
208 log_path: Option<&str>,
209 ) -> Result<(Task, Session)> {
210 let tx = self.conn.transaction()?;
211 reject_human_dispatch(&tx, task_id)?;
212 reject_archived_dispatch(&tx, task_id)?;
213 apply_action(&tx, task_id, Action::Start)?;
214 let session_id = insert_session(&tx, task_id, agent, pid, liveness_source, log_path)?;
215 tx.commit()?;
216 Ok((self.task(task_id)?, self.session(session_id)?))
217 }
218
219 pub fn record_refine_launch(
233 &mut self,
234 task_id: i64,
235 note: &str,
236 agent: &str,
237 pid: Option<i64>,
238 liveness_source: LivenessSource,
239 log_path: Option<&str>,
240 ) -> Result<(Task, Session)> {
241 let tx = self.conn.transaction()?;
242 apply_action(&tx, task_id, Action::Refine(note.to_string()))?;
243 let session_id = insert_session(&tx, task_id, agent, pid, liveness_source, log_path)?;
244 tx.commit()?;
245 Ok((self.task(task_id)?, self.session(session_id)?))
246 }
247
248 pub fn conclude_refine(&mut self, task_id: i64, outcome: RefineOutcome) -> Result<Task> {
259 self.apply(task_id, Action::ConcludeRefine(outcome))
260 }
261
262 pub fn reconcile_session(
286 &mut self,
287 session_id: i64,
288 pid_alive: bool,
289 likely_capped: bool,
290 ) -> Result<Option<(Session, Task)>> {
291 let tx = self.conn.transaction()?;
292 let session = get_session(&tx, session_id)?.ok_or(Error::SessionNotFound(session_id))?;
293 if session.ended_at.is_some() {
294 return Ok(None);
295 }
296 let task = get_task(&tx, session.task_id)?.ok_or(Error::TaskNotFound(session.task_id))?;
297
298 let outcome = match task.state {
299 TaskState::Running | TaskState::Refining => {
302 if pid_alive {
303 return Ok(None);
304 }
305 if likely_capped {
306 SessionOutcome::Capped
307 } else {
308 SessionOutcome::Failed
309 }
310 }
311 TaskState::NeedsInput | TaskState::Review | TaskState::Waiting => return Ok(None),
315 TaskState::Done => SessionOutcome::Completed,
318 TaskState::Rejected
319 | TaskState::Ready
320 | TaskState::Parked
321 | TaskState::Proposed
322 | TaskState::Stalled => SessionOutcome::Aborted,
323 };
324 set_session_outcome(&tx, session_id, outcome)?;
325
326 if matches!(task.state, TaskState::Running | TaskState::Refining) {
327 log_event(
328 &tx,
329 task.id,
330 "reconcile",
331 Some(&format!(
332 "session {session_id} ended without reporting ({outcome})"
333 )),
334 )?;
335 }
336 if task.state == TaskState::Running {
337 tx.execute(
338 "UPDATE tasks SET state = ?1, state_since = datetime('now') WHERE id = ?2",
339 params![TaskState::Stalled, task.id],
340 )?;
341 log_event(
342 &tx,
343 task.id,
344 "transition",
345 Some(&format!("{} -> {}", task.state, TaskState::Stalled)),
346 )?;
347 reconcile_readiness(&tx, task.id)?;
348 }
349 if task.state == TaskState::Refining {
353 apply_action(
354 &tx,
355 task.id,
356 Action::ConcludeRefine(crate::model::RefineOutcome::Failed),
357 )?;
358 }
359 tx.commit()?;
360 Ok(Some((
361 self.session(session_id)?,
362 self.task(session.task_id)?,
363 )))
364 }
365
366 pub fn set_blocks_deps(&mut self, task_id: i64, depends_on: &[i64]) -> Result<Task> {
372 let tx = self.conn.transaction()?;
373 if get_task(&tx, task_id)?.is_none() {
374 return Err(Error::TaskNotFound(task_id));
375 }
376 tx.execute(
377 "DELETE FROM deps WHERE task_id = ?1 AND kind = 'blocks'",
378 [task_id],
379 )?;
380 let mut seen = HashSet::new();
381 for dep in depends_on.iter().filter(|dep| seen.insert(**dep)) {
382 if get_task(&tx, *dep)?.is_none() {
383 return Err(Error::TaskNotFound(*dep));
384 }
385 reject_blocks_cycle(&tx, task_id, *dep)?;
386 tx.execute(
387 "INSERT INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, 'blocks')",
388 params![task_id, dep],
389 )?;
390 }
391 reconcile_readiness(&tx, task_id)?;
392 tx.commit()?;
393 self.task(task_id)
394 }
395
396 pub fn block_tasks(
405 &mut self,
406 blocker_id: i64,
407 dependents: &[i64],
408 ) -> Result<Vec<(Task, TaskState)>> {
409 let tx = self.conn.transaction()?;
410 if get_task(&tx, blocker_id)?.is_none() {
411 return Err(Error::TaskNotFound(blocker_id));
412 }
413 let mut affected = Vec::with_capacity(dependents.len());
414 for dep in dependents {
415 let before = get_task(&tx, *dep)?.ok_or(Error::TaskNotFound(*dep))?.state;
416 reject_blocks_cycle(&tx, *dep, blocker_id)?;
417 tx.execute(
418 "INSERT INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, 'blocks')
419 ON CONFLICT (task_id, depends_on, kind) DO NOTHING",
420 params![dep, blocker_id],
421 )?;
422 reconcile_readiness(&tx, *dep)?;
423 affected.push((*dep, before));
424 }
425 tx.commit()?;
426 affected
427 .into_iter()
428 .map(|(id, before)| Ok((self.task(id)?, before)))
429 .collect()
430 }
431}
432
433fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result<TaskState> {
439 let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
440
441 use TaskState::*;
442 let to = match (task.state, &action) {
443 (Proposed, Action::Triage(Triage::Parked)) => Parked,
444 (Proposed, Action::Triage(Triage::Ready)) => Ready,
445 (Proposed, Action::Triage(Triage::Reject)) => Rejected,
446 (Proposed | Ready, Action::Refine(_)) => Refining,
451 (Refining, Action::ConcludeRefine(_)) => Proposed,
452 (Ready | Stalled, Action::Start) => Running,
453 (Running, Action::Ask(_)) if task.human => {
456 return Err(Error::HumanTask {
457 id: task_id,
458 reason: "its executor is the human, who cannot be blocked on their own \
459 decision — file a follow-up task that blocks on this one instead"
460 .into(),
461 });
462 }
463 (Running, Action::Ask(_)) => NeedsInput,
464 (NeedsInput, Action::Resume) => Running,
465 (Running | Stalled, Action::Complete(_)) if task.human => Done,
468 (Running | Stalled, Action::Complete(_)) => Review,
472 (Review, Action::HandOff) => Waiting,
476 (Waiting, Action::Reclaim) => Review,
477 (Review | Waiting, Action::Accept) => Done,
478 (Review | Waiting, Action::RejectWork(_)) => Running,
479 (Running, Action::Abort) => Ready,
480 (Ready | Stalled, Action::Park) => Parked,
481 (Parked, Action::Unpark) => Ready,
482 (Parked | Ready | NeedsInput | Review | Waiting | Stalled, Action::Abandon) => Rejected,
483 _ => {
484 return Err(Error::InvalidTransition {
485 from: task.state,
486 action: action.name().to_string(),
487 });
488 }
489 };
490
491 match &action {
492 Action::Ask(q) if q.trim().is_empty() => {
493 return Err(Error::Invalid("a question is required".into()));
494 }
495 Action::RejectWork(f) if f.trim().is_empty() => {
496 return Err(Error::Invalid("rejection feedback is required".into()));
497 }
498 _ => {}
499 }
500 let question = match &action {
501 Action::Ask(q) => Some(q.trim().to_string()),
502 _ => None,
503 };
504
505 tx.execute(
506 "UPDATE tasks SET state = ?1, state_since = datetime('now'), question = ?2,
507 closed_at = CASE WHEN ?3 THEN datetime('now') ELSE closed_at END
508 WHERE id = ?4",
509 params![to, question, to.is_terminal(), task_id],
510 )?;
511 log_event(
512 tx,
513 task_id,
514 "transition",
515 Some(&format!("{} -> {}", task.state, to)),
516 )?;
517
518 match &action {
519 Action::RejectWork(f) => {
520 append_section(tx, task_id, "Feedback", f.trim())?;
521 log_event(tx, task_id, "feedback", Some(f.trim()))?;
522 }
523 Action::Complete(Some(s)) if !s.trim().is_empty() => {
524 log_event(tx, task_id, "summary", Some(s.trim()))?;
525 }
526 Action::Refine(note) if !note.trim().is_empty() => {
530 log_event(tx, task_id, "refined", Some(note.trim()))?;
531 }
532 Action::ConcludeRefine(outcome) => {
535 log_event(tx, task_id, "refine", Some(outcome.as_str()))?;
536 }
537 _ => {}
538 }
539
540 match &action {
546 Action::Accept => {
547 close_open_session(tx, task_id, SessionOutcome::Completed)?;
548 }
549 Action::Complete(_) if to == TaskState::Done => {
553 close_open_session(tx, task_id, SessionOutcome::Completed)?;
554 }
555 Action::Abort | Action::Abandon => {
556 close_open_session(tx, task_id, SessionOutcome::Aborted)?;
557 }
558 Action::ConcludeRefine(outcome) => {
562 close_open_session(tx, task_id, outcome.session_outcome())?;
563 }
564 _ => {}
565 }
566
567 if to.is_terminal() {
568 reconcile_dependants(tx, task_id)?;
569 } else if to == TaskState::Ready {
570 reconcile_readiness(tx, task_id)?;
574 }
575
576 Ok(to)
577}
578
579fn reject_human_dispatch(tx: &Connection, task_id: i64) -> Result<()> {
583 let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
584 if task.human {
585 return Err(Error::HumanTask {
586 id: task_id,
587 reason: "no agent can execute it — start it by hand instead".into(),
588 });
589 }
590 Ok(())
591}
592
593fn reject_archived_dispatch(tx: &Connection, task_id: i64) -> Result<()> {
598 let (name, archived): (String, bool) = tx.query_row(
599 "SELECT p.name, p.archived FROM projects p
600 JOIN tasks t ON t.project_id = p.id WHERE t.id = ?1",
601 [task_id],
602 |r| Ok((r.get(0)?, r.get(1)?)),
603 )?;
604 if archived {
605 return Err(Error::ProjectArchived { name });
606 }
607 Ok(())
608}
609
610pub(crate) fn reject_blocks_cycle(conn: &Connection, from: i64, to: i64) -> Result<()> {
614 if let Some(cycle) = find_blocks_cycle(conn, from, to)? {
615 let path = cycle
616 .iter()
617 .map(i64::to_string)
618 .collect::<Vec<_>>()
619 .join(" -> ");
620 return Err(Error::DependencyCycle(path));
621 }
622 Ok(())
623}
624
625fn find_blocks_cycle(conn: &Connection, from: i64, to: i64) -> Result<Option<Vec<i64>>> {
632 if from == to {
633 return Ok(Some(vec![from, from]));
634 }
635
636 let mut predecessor: HashMap<i64, i64> = HashMap::new();
639 let mut queue = VecDeque::new();
640 queue.push_back(to);
641
642 while let Some(node) = queue.pop_front() {
643 if node == from {
644 let mut path = vec![node];
645 let mut cur = node;
646 while cur != to {
647 cur = predecessor[&cur];
648 path.push(cur);
649 }
650 path.reverse(); let mut cycle = vec![from];
652 cycle.extend(path);
653 return Ok(Some(cycle));
654 }
655 let mut stmt = conn
656 .prepare_cached("SELECT depends_on FROM deps WHERE task_id = ?1 AND kind = 'blocks'")?;
657 let children: Vec<i64> = stmt
658 .query_map([node], |r| r.get(0))?
659 .collect::<rusqlite::Result<_>>()?;
660 for child in children {
661 if child != to && !predecessor.contains_key(&child) {
662 predecessor.insert(child, node);
663 queue.push_back(child);
664 }
665 }
666 }
667 Ok(None)
668}
669
670fn append_section(conn: &Connection, task_id: i64, heading: &str, text: &str) -> Result<()> {
673 let body: String = conn.query_row("SELECT body FROM tasks WHERE id = ?1", [task_id], |r| {
674 r.get(0)
675 })?;
676 let marker = format!("## {heading}");
677 let mut body = body.trim_end().to_string();
678 if !body.ends_with(&marker) && !body.contains(&format!("{marker}\n")) {
679 if !body.is_empty() {
680 body.push_str("\n\n");
681 }
682 body.push_str(&marker);
683 body.push('\n');
684 }
685 body.push_str(&format!("\n- {text}\n"));
686 conn.execute(
687 "UPDATE tasks SET body = ?1 WHERE id = ?2",
688 params![body, task_id],
689 )?;
690 Ok(())
691}
692
693fn reconcile_dependants(conn: &Connection, closed_id: i64) -> Result<()> {
697 let mut stmt =
698 conn.prepare("SELECT task_id FROM deps WHERE depends_on = ?1 AND kind = 'blocks'")?;
699 let dependants: Vec<i64> = stmt
700 .query_map([closed_id], |r| r.get(0))?
701 .collect::<rusqlite::Result<_>>()?;
702 for id in dependants {
703 reconcile_readiness(conn, id)?;
704 }
705 Ok(())
706}
707
708pub(crate) fn reconcile_readiness(conn: &Connection, task_id: i64) -> Result<()> {
715 let Some(task) = get_task(conn, task_id)? else {
716 return Ok(());
717 };
718 let (total, open): (i64, i64) = conn.query_row(
719 "SELECT COUNT(*),
720 COUNT(*) FILTER (WHERE b.state NOT IN ('done','rejected'))
721 FROM deps d JOIN tasks b ON b.id = d.depends_on
722 WHERE d.task_id = ?1 AND d.kind = 'blocks'",
723 [task_id],
724 |r| Ok((r.get(0)?, r.get(1)?)),
725 )?;
726
727 let to = match task.state {
728 TaskState::Parked if total > 0 && open == 0 => TaskState::Ready,
729 TaskState::Ready | TaskState::Stalled if open > 0 => TaskState::Parked,
730 _ => return Ok(()),
731 };
732 conn.execute(
733 "UPDATE tasks SET state = ?1, state_since = datetime('now') WHERE id = ?2",
734 params![to, task_id],
735 )?;
736 let reason = if to == TaskState::Ready {
737 "unblocked"
738 } else {
739 "blocked"
740 };
741 log_event(
742 conn,
743 task_id,
744 "transition",
745 Some(&format!("{} -> {} ({reason})", task.state, to)),
746 )?;
747 Ok(())
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::model::{DepKind, Priority};
754 use crate::store::NewTask;
755
756 fn store_with_project() -> (Store, i64) {
757 let mut s = Store::open_in_memory().unwrap();
758 let p = s.create_project("proj", "/tmp/proj").unwrap();
759 (s, p.id)
760 }
761
762 fn create(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
763 s.create_task(NewTask {
764 project_id,
765 repo_id: None,
766 title: format!("task in {state}"),
767 body: String::new(),
768 priority: Priority::P1,
769 state,
770 agent: None,
771 human: false,
772 deep: false,
773 })
774 .unwrap()
775 .id
776 }
777
778 fn task_in_state(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
780 use TaskState::*;
781 match state {
782 Proposed | Parked | Ready => create(s, project_id, state),
783 Refining => {
784 let id = create(s, project_id, Proposed);
785 s.record_refine_launch(
786 id,
787 "thin body",
788 "claude",
789 Some(1),
790 LivenessSource::Pid,
791 None,
792 )
793 .unwrap();
794 id
795 }
796 Running => {
797 let id = create(s, project_id, Ready);
798 s.apply(id, Action::Start).unwrap();
799 id
800 }
801 NeedsInput => {
802 let id = task_in_state(s, project_id, Running);
803 s.apply(id, Action::Ask("which schema?".into())).unwrap();
804 id
805 }
806 Review => {
807 let id = task_in_state(s, project_id, Running);
808 s.apply(id, Action::Complete(None)).unwrap();
809 id
810 }
811 Waiting => {
812 let id = task_in_state(s, project_id, Review);
813 s.apply(id, Action::HandOff).unwrap();
814 id
815 }
816 Stalled => {
817 let id = create(s, project_id, Ready);
818 let (_, session) = s
819 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
820 .unwrap();
821 s.reconcile_session(session.id, false, false).unwrap();
822 id
823 }
824 Done => {
825 let id = task_in_state(s, project_id, Review);
826 s.apply(id, Action::Accept).unwrap();
827 id
828 }
829 Rejected => {
830 let id = create(s, project_id, Proposed);
831 s.apply(id, Action::Triage(Triage::Reject)).unwrap();
832 id
833 }
834 }
835 }
836
837 fn all_actions() -> Vec<Action> {
838 vec![
839 Action::Triage(Triage::Parked),
840 Action::Triage(Triage::Ready),
841 Action::Triage(Triage::Reject),
842 Action::Refine("thin body".into()),
843 Action::ConcludeRefine(RefineOutcome::Applied),
844 Action::Start,
845 Action::Ask("q?".into()),
846 Action::Resume,
847 Action::Complete(None),
848 Action::HandOff,
849 Action::Reclaim,
850 Action::Accept,
851 Action::RejectWork("redo".into()),
852 Action::Abort,
853 Action::Park,
854 Action::Unpark,
855 Action::Abandon,
856 ]
857 }
858
859 fn expected(state: TaskState, action: &Action) -> Option<TaskState> {
862 use TaskState::*;
863 match (state, action) {
864 (Proposed, Action::Triage(Triage::Parked)) => Some(Parked),
865 (Proposed, Action::Triage(Triage::Ready)) => Some(Ready),
866 (Proposed, Action::Triage(Triage::Reject)) => Some(Rejected),
867 (Proposed | Ready, Action::Refine(_)) => Some(Refining),
868 (Refining, Action::ConcludeRefine(_)) => Some(Proposed),
869 (Ready | Stalled, Action::Start) => Some(Running),
870 (Ready | Stalled, Action::Park) => Some(Parked),
871 (Parked, Action::Unpark) => Some(Ready),
872 (Running, Action::Ask(_)) => Some(NeedsInput),
873 (Running | Stalled, Action::Complete(_)) => Some(Review),
874 (Running, Action::Abort) => Some(Ready),
875 (NeedsInput, Action::Resume) => Some(Running),
876 (Review, Action::HandOff) => Some(Waiting),
877 (Waiting, Action::Reclaim) => Some(Review),
878 (Review | Waiting, Action::Accept) => Some(Done),
879 (Review | Waiting, Action::RejectWork(_)) => Some(Running),
880 (Parked | Ready | NeedsInput | Review | Waiting | Stalled, Action::Abandon) => {
881 Some(Rejected)
882 }
883 _ => None,
884 }
885 }
886
887 #[test]
888 fn full_transition_matrix() {
889 for state in TaskState::ALL {
890 for action in all_actions() {
891 let (mut s, p) = store_with_project();
892 let id = task_in_state(&mut s, p, state);
893 let result = s.apply(id, action.clone());
894 match expected(state, &action) {
895 Some(to) => {
896 let task = result.unwrap_or_else(|e| {
897 panic!("{state} + {action:?} should reach {to}: {e}")
898 });
899 assert_eq!(task.state, to, "{state} + {action:?}");
900 }
901 None => {
902 assert!(
903 matches!(result, Err(Error::InvalidTransition { .. })),
904 "{state} + {action:?} should be rejected"
905 );
906 }
907 }
908 }
909 }
910 }
911
912 #[test]
917 fn legal_actions_agrees_with_apply() {
918 for state in TaskState::ALL {
919 let legal = Store::legal_actions(state, false);
920 for action in all_actions() {
921 if matches!(action, Action::Refine(_)) {
922 assert!(
923 !legal.iter().any(|l| matches!(l, Action::Refine(_))),
924 "the verdict menu must not offer refine ({state})"
925 );
926 continue;
927 }
928 let in_legal = legal
929 .iter()
930 .any(|l| std::mem::discriminant(l) == std::mem::discriminant(&action))
931 && match (&action, state) {
932 (Action::Triage(_), s) => s == TaskState::Proposed,
935 (Action::ConcludeRefine(_), s) => s == TaskState::Refining,
939 _ => true,
940 };
941 assert_eq!(
942 expected(state, &action).is_some(),
943 in_legal,
944 "legal_actions disagrees for {state} + {action:?}"
945 );
946 }
947 }
948 }
949
950 mod refine {
953 use super::*;
954
955 #[test]
959 fn a_ready_task_can_be_refined_in_either_flavour() {
960 for note in ["the body names no files", ""] {
961 let (mut s, p) = store_with_project();
962 let id = create(&mut s, p, TaskState::Ready);
963
964 let (task, session) = s
965 .record_refine_launch(id, note, "claude", Some(4242), LivenessSource::Pid, None)
966 .unwrap();
967 assert_eq!(task.state, TaskState::Refining);
968 assert_eq!(session.task_id, id);
969 assert!(session.ended_at.is_none());
970 assert!(
971 s.events_for(id)
972 .unwrap()
973 .iter()
974 .any(|e| e.detail.as_deref() == Some("ready -> refining")),
975 "the transition is logged"
976 );
977 }
978 }
979
980 #[test]
984 fn every_round_concludes_to_proposed_whatever_it_started_from() {
985 for from in [TaskState::Proposed, TaskState::Ready] {
986 for outcome in [
987 RefineOutcome::Applied,
988 RefineOutcome::Failed,
989 RefineOutcome::Cancelled,
990 ] {
991 let (mut s, p) = store_with_project();
992 let id = create(&mut s, p, from);
993 s.record_refine_launch(
994 id,
995 "thin body",
996 "claude",
997 Some(1),
998 LivenessSource::Pid,
999 None,
1000 )
1001 .unwrap();
1002
1003 let task = s.conclude_refine(id, outcome).unwrap();
1004 assert_eq!(task.state, TaskState::Proposed, "{from} + {outcome:?}");
1005 }
1006 }
1007 }
1008
1009 #[test]
1013 fn refine_is_refused_everywhere_but_proposed_and_ready() {
1014 for state in TaskState::ALL {
1015 if matches!(state, TaskState::Proposed | TaskState::Ready) {
1016 continue;
1017 }
1018 let (mut s, p) = store_with_project();
1019 let id = task_in_state(&mut s, p, state);
1020 let result = s.apply(id, Action::Refine("thin body".into()));
1021 assert!(
1022 matches!(result, Err(Error::InvalidTransition { .. })),
1023 "refine from {state} should be refused"
1024 );
1025 }
1026 }
1027
1028 #[test]
1033 fn a_refining_task_leaves_the_ready_work_queue() {
1034 let (mut s, p) = store_with_project();
1035 s.set_weight(p, 3).unwrap();
1036 let id = create(&mut s, p, TaskState::Ready);
1037 assert!(crate::scheduler::focus(&s.candidates().unwrap()).is_some());
1038
1039 s.record_refine_launch(
1040 id,
1041 "thin body",
1042 "claude",
1043 Some(1),
1044 LivenessSource::Pid,
1045 None,
1046 )
1047 .unwrap();
1048 let candidates = s.candidates().unwrap();
1049 assert!(
1050 !candidates.iter().any(|c| c.task.id == id),
1051 "a refining task is not a scheduler candidate"
1052 );
1053 assert!(crate::scheduler::focus(&candidates).is_none());
1054 }
1055 }
1056
1057 mod human {
1060 use super::*;
1061
1062 fn create_human(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
1063 s.create_task(NewTask {
1064 project_id,
1065 repo_id: None,
1066 title: format!("human task in {state}"),
1067 body: String::new(),
1068 priority: Priority::P1,
1069 state,
1070 agent: None,
1071 human: true,
1072 deep: false,
1073 })
1074 .unwrap()
1075 .id
1076 }
1077
1078 #[test]
1079 fn completion_goes_straight_to_done() {
1080 let (mut s, p) = store_with_project();
1081 let id = create_human(&mut s, p, TaskState::Ready);
1082 s.apply(id, Action::Start).unwrap();
1083
1084 let task = s
1085 .apply(id, Action::Complete(Some("bag captured".into())))
1086 .unwrap();
1087 assert_eq!(task.state, TaskState::Done);
1088 assert!(task.closed_at.is_some());
1089
1090 let events = s.events_for(id).unwrap();
1091 assert!(
1092 events
1093 .iter()
1094 .any(|e| e.detail.as_deref() == Some("running -> done")),
1095 "{events:?}"
1096 );
1097 assert!(
1099 events
1100 .iter()
1101 .any(|e| e.kind == "summary" && e.detail.as_deref() == Some("bag captured"))
1102 );
1103 }
1104
1105 #[test]
1106 fn ask_is_refused_and_writes_nothing() {
1107 let (mut s, p) = store_with_project();
1108 let id = create_human(&mut s, p, TaskState::Ready);
1109 s.apply(id, Action::Start).unwrap();
1110
1111 let err = s.apply(id, Action::Ask("which bag?".into())).unwrap_err();
1112 assert!(
1113 matches!(err, Error::HumanTask { id: e, .. } if e == id),
1114 "expected a human-only refusal, got {err}"
1115 );
1116 let task = s.task(id).unwrap();
1117 assert_eq!(task.state, TaskState::Running);
1118 assert!(task.question.is_none());
1119 }
1120
1121 #[test]
1122 fn record_dispatch_is_refused_and_writes_nothing() {
1123 let (mut s, p) = store_with_project();
1124 let id = create_human(&mut s, p, TaskState::Ready);
1125
1126 let err = s
1127 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1128 .unwrap_err();
1129 assert!(
1130 matches!(err, Error::HumanTask { id: e, .. } if e == id),
1131 "expected a human-only refusal, got {err}"
1132 );
1133 assert_eq!(s.task(id).unwrap().state, TaskState::Ready);
1134 assert!(s.sessions_for(id).unwrap().is_empty());
1135 }
1136
1137 #[test]
1138 fn completion_unblocks_dependants() {
1139 let (mut s, p) = store_with_project();
1142 let blocker = create_human(&mut s, p, TaskState::Ready);
1143 let dependant = create(&mut s, p, TaskState::Parked);
1144 s.add_dep(dependant, blocker, DepKind::Blocks).unwrap();
1145
1146 s.apply(blocker, Action::Start).unwrap();
1147 s.apply(blocker, Action::Complete(None)).unwrap();
1148 assert_eq!(s.task(dependant).unwrap().state, TaskState::Ready);
1149 }
1150
1151 #[test]
1152 fn completion_closes_a_stray_open_session() {
1153 let (mut s, p) = store_with_project();
1157 let id = create_human(&mut s, p, TaskState::Ready);
1158 s.apply(id, Action::Start).unwrap();
1159 let stray = s
1160 .create_session(id, "claude", Some(1), LivenessSource::Pid, None)
1161 .unwrap();
1162
1163 s.apply(id, Action::Complete(None)).unwrap();
1164 let closed = s.session(stray.id).unwrap();
1165 assert!(closed.ended_at.is_some());
1166 assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1167 }
1168
1169 #[test]
1170 fn legal_actions_omit_ask_and_agree_with_apply() {
1171 let legal = Store::legal_actions(TaskState::Running, true);
1172 assert_eq!(legal, vec![Action::Complete(None), Action::Abort]);
1173 for state in TaskState::ALL {
1175 if state != TaskState::Running {
1176 assert_eq!(
1177 Store::legal_actions(state, true),
1178 Store::legal_actions(state, false),
1179 "{state}"
1180 );
1181 }
1182 }
1183 }
1184
1185 #[test]
1188 fn full_transition_matrix_for_human_tasks() {
1189 use TaskState::*;
1190 for state in [Proposed, Parked, Ready, Running] {
1191 for action in all_actions() {
1192 let (mut s, p) = store_with_project();
1193 let id = create_human(&mut s, p, if state == Running { Ready } else { state });
1194 if state == Running {
1195 s.apply(id, Action::Start).unwrap();
1196 }
1197 let result = s.apply(id, action.clone());
1198 let expected = match (state, &action) {
1199 (Running, Action::Ask(_)) => None,
1201 (Running, Action::Complete(_)) => Some(Done),
1202 _ => expected(state, &action),
1203 };
1204 match expected {
1205 Some(to) => {
1206 let task = result.unwrap_or_else(|e| {
1207 panic!("human {state} + {action:?} should reach {to}: {e}")
1208 });
1209 assert_eq!(task.state, to, "human {state} + {action:?}");
1210 }
1211 None => {
1212 assert!(
1213 matches!(
1214 result,
1215 Err(Error::InvalidTransition { .. } | Error::HumanTask { .. })
1216 ),
1217 "human {state} + {action:?} should be rejected"
1218 );
1219 }
1220 }
1221 }
1222 }
1223 }
1224 }
1225
1226 #[test]
1227 fn transitions_restamp_state_since_and_log_events() {
1228 let (mut s, p) = store_with_project();
1229 let id = create(&mut s, p, TaskState::Ready);
1230 s.conn
1231 .execute(
1232 "UPDATE tasks SET state_since = '2000-01-01 00:00:00' WHERE id = ?1",
1233 [id],
1234 )
1235 .unwrap();
1236 let task = s.apply(id, Action::Start).unwrap();
1237 assert_ne!(task.state_since, "2000-01-01 00:00:00");
1238 let events = s.events_for(id).unwrap();
1239 assert_eq!(events.last().unwrap().kind, "transition");
1240 assert_eq!(
1241 events.last().unwrap().detail.as_deref(),
1242 Some("ready -> running")
1243 );
1244 }
1245
1246 #[test]
1247 fn question_is_set_iff_needs_input() {
1248 let (mut s, p) = store_with_project();
1249 let id = task_in_state(&mut s, p, TaskState::Running);
1250 let task = s.apply(id, Action::Ask(" A or B? ".into())).unwrap();
1251 assert_eq!(task.question.as_deref(), Some("A or B?"));
1252 let task = s.apply(id, Action::Resume).unwrap();
1253 assert_eq!(task.state, TaskState::Running);
1254 assert!(task.question.is_none());
1255
1256 let id = task_in_state(&mut s, p, TaskState::NeedsInput);
1257 let task = s.apply(id, Action::Abandon).unwrap();
1258 assert!(task.question.is_none());
1259 }
1260
1261 #[test]
1262 fn empty_question_and_feedback_are_rejected() {
1263 let (mut s, p) = store_with_project();
1264 let running = task_in_state(&mut s, p, TaskState::Running);
1265 assert!(s.apply(running, Action::Ask(" ".into())).is_err());
1266 let review = task_in_state(&mut s, p, TaskState::Review);
1267 assert!(s.apply(review, Action::RejectWork(" ".into())).is_err());
1268 assert_eq!(s.task(review).unwrap().state, TaskState::Review);
1270 }
1271
1272 #[test]
1276 fn resume_records_no_answer_and_leaves_the_body_untouched() {
1277 let (mut s, p) = store_with_project();
1278 let id = task_in_state(&mut s, p, TaskState::NeedsInput);
1279 let before = s.task(id).unwrap().body;
1280
1281 let task = s.apply(id, Action::Resume).unwrap();
1282 assert_eq!(task.state, TaskState::Running);
1283 assert_eq!(task.body, before, "resume must not append to the body");
1284 assert!(task.question.is_none());
1285
1286 let events = s.events_for(id).unwrap();
1287 assert!(
1288 events.iter().all(|e| e.kind != "answer"),
1289 "no answer event is logged: {events:?}"
1290 );
1291 assert_eq!(
1292 events.last().unwrap().detail.as_deref(),
1293 Some("needs-input -> running")
1294 );
1295 }
1296
1297 #[test]
1299 fn resume_is_refused_outside_needs_input() {
1300 use TaskState::*;
1301 for state in [Ready, Running, Review, Waiting, Stalled] {
1302 let (mut s, p) = store_with_project();
1303 let id = task_in_state(&mut s, p, state);
1304 assert!(
1305 matches!(
1306 s.apply(id, Action::Resume),
1307 Err(Error::InvalidTransition { .. })
1308 ),
1309 "resume should be refused from {state}"
1310 );
1311 }
1312 }
1313
1314 #[test]
1315 fn feedback_accumulates_in_body() {
1316 let (mut s, p) = store_with_project();
1317 let id = task_in_state(&mut s, p, TaskState::Review);
1318 let task = s
1319 .apply(id, Action::RejectWork("tests missing".into()))
1320 .unwrap();
1321 assert!(task.body.contains("## Feedback"));
1322 assert!(task.body.contains("- tests missing"));
1323
1324 s.apply(id, Action::Complete(None)).unwrap();
1325 let task = s.apply(id, Action::RejectWork("also lint".into())).unwrap();
1326 assert_eq!(task.body.matches("## Feedback").count(), 1);
1327 assert!(task.body.contains("- also lint"));
1328 assert_eq!(
1329 s.events_for(id)
1330 .unwrap()
1331 .iter()
1332 .filter(|e| e.kind == "feedback")
1333 .count(),
1334 2
1335 );
1336 }
1337
1338 #[test]
1339 fn complete_summary_is_logged_as_an_event() {
1340 let (mut s, p) = store_with_project();
1341 let id = task_in_state(&mut s, p, TaskState::Running);
1342 s.apply(
1343 id,
1344 Action::Complete(Some("Implemented X, tests pass".into())),
1345 )
1346 .unwrap();
1347 let summaries: Vec<_> = s
1348 .events_for(id)
1349 .unwrap()
1350 .into_iter()
1351 .filter(|e| e.kind == "summary")
1352 .collect();
1353 assert_eq!(summaries.len(), 1);
1354 assert_eq!(
1355 summaries[0].detail.as_deref(),
1356 Some("Implemented X, tests pass")
1357 );
1358 }
1359
1360 #[test]
1361 fn complete_without_summary_logs_no_summary_event() {
1362 let (mut s, p) = store_with_project();
1363 let id = task_in_state(&mut s, p, TaskState::Running);
1364 s.apply(id, Action::Complete(None)).unwrap();
1365 let blank = task_in_state(&mut s, p, TaskState::Running);
1366 s.apply(blank, Action::Complete(Some(" ".into())))
1367 .unwrap();
1368 assert!(
1369 s.events_for(id)
1370 .unwrap()
1371 .iter()
1372 .all(|e| e.kind != "summary")
1373 );
1374 assert!(
1375 s.events_for(blank)
1376 .unwrap()
1377 .iter()
1378 .all(|e| e.kind != "summary")
1379 );
1380 }
1381
1382 #[test]
1383 fn closing_stamps_closed_at() {
1384 let (mut s, p) = store_with_project();
1385 let id = task_in_state(&mut s, p, TaskState::Done);
1386 assert!(s.task(id).unwrap().closed_at.is_some());
1387 let id = task_in_state(&mut s, p, TaskState::Rejected);
1388 assert!(s.task(id).unwrap().closed_at.is_some());
1389 let id = task_in_state(&mut s, p, TaskState::Review);
1390 assert!(s.task(id).unwrap().closed_at.is_none());
1391 }
1392
1393 #[test]
1394 fn accepting_last_blocker_promotes_dependant() {
1395 let (mut s, p) = store_with_project();
1396 let blocker = task_in_state(&mut s, p, TaskState::Review);
1397 let dependant = create(&mut s, p, TaskState::Parked);
1398 s.add_dep(dependant, blocker, DepKind::Blocks).unwrap();
1399
1400 s.apply(blocker, Action::Accept).unwrap();
1401 let task = s.task(dependant).unwrap();
1402 assert_eq!(task.state, TaskState::Ready);
1403 let events = s.events_for(dependant).unwrap();
1404 assert_eq!(
1405 events.last().unwrap().detail.as_deref(),
1406 Some("parked -> ready (unblocked)")
1407 );
1408 }
1409
1410 #[test]
1411 fn promotion_waits_for_the_last_blocker() {
1412 let (mut s, p) = store_with_project();
1413 let b1 = task_in_state(&mut s, p, TaskState::Review);
1414 let b2 = create(&mut s, p, TaskState::Ready);
1415 let dependant = create(&mut s, p, TaskState::Parked);
1416 s.add_dep(dependant, b1, DepKind::Blocks).unwrap();
1417 s.add_dep(dependant, b2, DepKind::Blocks).unwrap();
1418
1419 s.apply(b1, Action::Accept).unwrap();
1420 assert_eq!(s.task(dependant).unwrap().state, TaskState::Parked);
1421
1422 s.apply(b2, Action::Abandon).unwrap();
1424 assert_eq!(s.task(dependant).unwrap().state, TaskState::Ready);
1425 }
1426
1427 #[test]
1428 fn parked_task_without_blockers_is_never_auto_promoted() {
1429 let (mut s, p) = store_with_project();
1430 let parked = create(&mut s, p, TaskState::Parked);
1431 let unrelated = task_in_state(&mut s, p, TaskState::Review);
1432 s.apply(unrelated, Action::Accept).unwrap();
1433 assert_eq!(s.task(parked).unwrap().state, TaskState::Parked);
1434 }
1435
1436 #[test]
1437 fn only_blocks_deps_gate_readiness() {
1438 let (mut s, p) = store_with_project();
1439 let other = create(&mut s, p, TaskState::Ready);
1440 let task = create(&mut s, p, TaskState::Ready);
1441 for kind in [DepKind::DiscoveredFrom, DepKind::Parent, DepKind::Related] {
1442 s.add_dep(task, other, kind).unwrap();
1443 }
1444 assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1445 }
1446
1447 #[test]
1450 fn set_blocks_deps_coexists_with_a_discovered_from_edge() {
1451 let (mut s, p) = store_with_project();
1452 let source = create(&mut s, p, TaskState::Ready);
1453 let task = create(&mut s, p, TaskState::Ready);
1454 s.add_dep(task, source, DepKind::DiscoveredFrom).unwrap();
1455
1456 let after = s.set_blocks_deps(task, &[source]).unwrap();
1457 assert_eq!(after.state, TaskState::Parked);
1458 let kinds: Vec<DepKind> = s.deps_of(task).unwrap().iter().map(|d| d.kind).collect();
1459 assert_eq!(kinds, vec![DepKind::Blocks, DepKind::DiscoveredFrom]);
1460 }
1461
1462 #[test]
1464 fn block_tasks_coexists_with_a_discovered_from_edge() {
1465 let (mut s, p) = store_with_project();
1466 let source = create(&mut s, p, TaskState::Ready);
1467 let task = create(&mut s, p, TaskState::Ready);
1468 s.add_dep(task, source, DepKind::DiscoveredFrom).unwrap();
1469
1470 let affected = s.block_tasks(source, &[task]).unwrap();
1471 assert_eq!(affected[0].0.state, TaskState::Parked);
1472 assert_eq!(affected[0].1, TaskState::Ready);
1473 let kinds: Vec<DepKind> = s.deps_of(task).unwrap().iter().map(|d| d.kind).collect();
1474 assert_eq!(kinds, vec![DepKind::Blocks, DepKind::DiscoveredFrom]);
1475
1476 s.block_tasks(source, &[task]).unwrap();
1478 assert_eq!(s.deps_of(task).unwrap().len(), 2);
1479 }
1480
1481 #[test]
1483 fn set_blocks_deps_tolerates_a_repeated_id() {
1484 let (mut s, p) = store_with_project();
1485 let blocker = create(&mut s, p, TaskState::Ready);
1486 let task = create(&mut s, p, TaskState::Ready);
1487 s.set_blocks_deps(task, &[blocker, blocker]).unwrap();
1488 assert_eq!(s.deps_of(task).unwrap().len(), 1);
1489 }
1490
1491 #[test]
1492 fn adding_open_blocker_demotes_ready_task() {
1493 let (mut s, p) = store_with_project();
1494 let blocker = create(&mut s, p, TaskState::Ready);
1495 let task = create(&mut s, p, TaskState::Ready);
1496 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1497 let demoted = s.task(task).unwrap();
1498 assert_eq!(demoted.state, TaskState::Parked);
1499
1500 s.apply(blocker, Action::Start).unwrap();
1502 s.apply(blocker, Action::Complete(None)).unwrap();
1503 s.apply(blocker, Action::Accept).unwrap();
1504 assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1505 }
1506
1507 #[test]
1508 fn triaging_to_ready_parks_a_blocked_task() {
1509 let (mut s, p) = store_with_project();
1510 let blocker = create(&mut s, p, TaskState::Ready);
1511 let task = create(&mut s, p, TaskState::Proposed);
1512 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1513
1514 let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1517 assert_eq!(triaged.state, TaskState::Parked);
1518
1519 s.apply(blocker, Action::Start).unwrap();
1521 s.apply(blocker, Action::Complete(None)).unwrap();
1522 s.apply(blocker, Action::Accept).unwrap();
1523 assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1524 }
1525
1526 #[test]
1527 fn triaging_to_ready_stays_ready_when_unblocked() {
1528 let (mut s, p) = store_with_project();
1529 let closed = task_in_state(&mut s, p, TaskState::Done);
1530 let task = create(&mut s, p, TaskState::Proposed);
1531 s.add_dep(task, closed, DepKind::Blocks).unwrap();
1532 let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1534 assert_eq!(triaged.state, TaskState::Ready);
1535 }
1536
1537 #[test]
1538 fn aborting_parks_a_task_blocked_while_running() {
1539 let (mut s, p) = store_with_project();
1540 let task = task_in_state(&mut s, p, TaskState::Running);
1541 let blocker = create(&mut s, p, TaskState::Ready);
1542 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1544 assert_eq!(s.task(task).unwrap().state, TaskState::Running);
1545 let aborted = s.apply(task, Action::Abort).unwrap();
1547 assert_eq!(aborted.state, TaskState::Parked);
1548 }
1549
1550 #[test]
1551 fn unparking_a_blocked_task_reparks_it() {
1552 let (mut s, p) = store_with_project();
1553 let blocker = create(&mut s, p, TaskState::Ready);
1554 let task = create(&mut s, p, TaskState::Ready);
1555 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1556 assert_eq!(s.task(task).unwrap().state, TaskState::Parked);
1557 let unparked = s.apply(task, Action::Unpark).unwrap();
1559 assert_eq!(unparked.state, TaskState::Parked);
1560 }
1561
1562 #[test]
1563 fn set_blocks_deps_replaces_and_reconciles() {
1564 let (mut s, p) = store_with_project();
1565 let open = create(&mut s, p, TaskState::Ready);
1566 let closed = task_in_state(&mut s, p, TaskState::Done);
1567 let task = create(&mut s, p, TaskState::Ready);
1568
1569 let t = s.set_blocks_deps(task, &[open, closed]).unwrap();
1570 assert_eq!(t.state, TaskState::Parked);
1571
1572 let t = s.set_blocks_deps(task, &[closed]).unwrap();
1574 assert_eq!(t.state, TaskState::Ready);
1575
1576 assert!(s.set_blocks_deps(task, &[task]).is_err());
1577 assert!(s.set_blocks_deps(task, &[9999]).is_err());
1578 }
1579
1580 #[test]
1581 fn block_tasks_demotes_ready_dependents_in_the_same_write() {
1582 let (mut s, p) = store_with_project();
1583 let blocker = create(&mut s, p, TaskState::Ready);
1584 let ready = create(&mut s, p, TaskState::Ready);
1585 let parked = create(&mut s, p, TaskState::Parked);
1586
1587 let affected = s.block_tasks(blocker, &[ready, parked]).unwrap();
1588 let states: Vec<_> = affected
1589 .iter()
1590 .map(|(t, before)| (t.id, *before, t.state))
1591 .collect();
1592 assert_eq!(
1593 states,
1594 vec![
1595 (ready, TaskState::Ready, TaskState::Parked),
1596 (parked, TaskState::Parked, TaskState::Parked),
1597 ]
1598 );
1599
1600 s.apply(blocker, Action::Start).unwrap();
1602 s.apply(blocker, Action::Complete(None)).unwrap();
1603 s.apply(blocker, Action::Accept).unwrap();
1604 assert_eq!(s.task(ready).unwrap().state, TaskState::Ready);
1605 assert_eq!(s.task(parked).unwrap().state, TaskState::Ready);
1606 }
1607
1608 #[test]
1609 fn block_tasks_is_additive_and_idempotent() {
1610 let (mut s, p) = store_with_project();
1611 let existing = create(&mut s, p, TaskState::Ready);
1612 let blocker = create(&mut s, p, TaskState::Ready);
1613 let task = create(&mut s, p, TaskState::Ready);
1614 s.set_blocks_deps(task, &[existing]).unwrap();
1615
1616 s.block_tasks(blocker, &[task]).unwrap();
1617 s.block_tasks(blocker, &[task]).unwrap();
1618 let deps = s.deps_of(task).unwrap();
1619 assert_eq!(deps.len(), 2, "{deps:?}");
1620 }
1621
1622 #[test]
1623 fn block_tasks_rejects_cycles_and_unknown_tasks() {
1624 let (mut s, p) = store_with_project();
1625 let a = create(&mut s, p, TaskState::Ready);
1626 let b = create(&mut s, p, TaskState::Ready);
1627 let c = create(&mut s, p, TaskState::Ready);
1628 s.set_blocks_deps(b, &[a]).unwrap();
1630 s.set_blocks_deps(c, &[b]).unwrap();
1631 let err = s.block_tasks(c, &[a]).unwrap_err();
1632 assert!(matches!(err, Error::DependencyCycle(_)), "{err:?}");
1633
1634 assert!(s.block_tasks(a, &[a]).is_err());
1636 assert!(s.block_tasks(a, &[9999]).is_err());
1637 assert!(s.block_tasks(9999, &[a]).is_err());
1638 assert!(s.deps_of(a).unwrap().is_empty());
1639 }
1640
1641 #[test]
1642 fn record_dispatch_starts_the_task_and_opens_a_session() {
1643 let (mut s, p) = store_with_project();
1644 let id = create(&mut s, p, TaskState::Ready);
1645 let (task, session) = s
1646 .record_dispatch(
1647 id,
1648 "claude",
1649 Some(4321),
1650 LivenessSource::Pid,
1651 Some("/var/log/26.log"),
1652 )
1653 .unwrap();
1654 assert_eq!(task.state, TaskState::Running);
1655 assert_eq!(session.task_id, id);
1656 assert_eq!(session.agent, "claude");
1657 assert_eq!(session.pid, Some(4321));
1658 assert_eq!(session.log_path.as_deref(), Some("/var/log/26.log"));
1659 assert!(session.ended_at.is_none());
1660 assert_eq!(s.sessions_for(id).unwrap().len(), 1);
1661 }
1662
1663 #[test]
1664 fn record_dispatch_on_a_non_ready_task_writes_nothing() {
1665 let (mut s, p) = store_with_project();
1666 let id = create(&mut s, p, TaskState::Proposed);
1667 assert!(matches!(
1668 s.record_dispatch(id, "claude", None, LivenessSource::Pid, None),
1669 Err(Error::InvalidTransition { .. })
1670 ));
1671 assert_eq!(s.task(id).unwrap().state, TaskState::Proposed);
1673 assert!(s.sessions_for(id).unwrap().is_empty());
1674 }
1675
1676 #[test]
1677 fn dispatch_refuses_a_task_in_an_archived_project() {
1678 let (mut s, p) = store_with_project();
1681 let ready = create(&mut s, p, TaskState::Ready);
1682 s.set_archived(p, true).unwrap();
1683
1684 let err = s
1685 .record_dispatch(ready, "claude", Some(1), LivenessSource::Pid, None)
1686 .unwrap_err();
1687 assert!(matches!(err, Error::ProjectArchived { .. }), "{err}");
1688 assert_eq!(s.task(ready).unwrap().state, TaskState::Ready);
1689 assert!(s.sessions_for(ready).unwrap().is_empty());
1690
1691 s.set_archived(p, false).unwrap();
1693 assert!(
1694 s.record_dispatch(ready, "claude", Some(1), LivenessSource::Pid, None)
1695 .is_ok()
1696 );
1697 }
1698
1699 #[test]
1702 fn terminal_transitions_close_the_open_session_with_the_right_outcome() {
1703 let (mut s, p) = store_with_project();
1704
1705 let accepted = create(&mut s, p, TaskState::Ready);
1708 let sess = s
1709 .record_dispatch(accepted, "claude", Some(1), LivenessSource::Pid, None)
1710 .unwrap()
1711 .1;
1712 s.apply(accepted, Action::Complete(None)).unwrap();
1713 assert!(
1714 s.session(sess.id).unwrap().ended_at.is_none(),
1715 "review keeps it open"
1716 );
1717 s.apply(accepted, Action::Accept).unwrap();
1718 let closed = s.session(sess.id).unwrap();
1719 assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1720 assert!(closed.ended_at.is_some());
1721
1722 let aborted = create(&mut s, p, TaskState::Ready);
1724 let sess = s
1725 .record_dispatch(aborted, "claude", Some(1), LivenessSource::Pid, None)
1726 .unwrap()
1727 .1;
1728 s.apply(aborted, Action::Abort).unwrap();
1729 assert_eq!(
1730 s.session(sess.id).unwrap().outcome,
1731 Some(SessionOutcome::Aborted)
1732 );
1733
1734 let abandoned = create(&mut s, p, TaskState::Ready);
1736 let sess = s
1737 .record_dispatch(abandoned, "claude", Some(1), LivenessSource::Pid, None)
1738 .unwrap()
1739 .1;
1740 s.apply(abandoned, Action::Complete(None)).unwrap();
1741 s.apply(abandoned, Action::Abandon).unwrap();
1742 assert_eq!(
1743 s.session(sess.id).unwrap().outcome,
1744 Some(SessionOutcome::Aborted)
1745 );
1746 }
1747
1748 #[test]
1749 fn ask_and_resume_keep_the_session_open_across_needs_input() {
1750 let (mut s, p) = store_with_project();
1754 let id = create(&mut s, p, TaskState::Ready);
1755 let sess = s
1756 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1757 .unwrap()
1758 .1;
1759
1760 s.apply(id, Action::Ask("A or B?".into())).unwrap();
1761 assert!(s.session(sess.id).unwrap().ended_at.is_none());
1762 s.apply(id, Action::Resume).unwrap();
1763 assert!(
1764 s.session(sess.id).unwrap().ended_at.is_none(),
1765 "resume keeps the session open"
1766 );
1767 assert_eq!(s.task(id).unwrap().state, TaskState::Running);
1768 }
1769
1770 #[test]
1771 fn rejecting_a_review_keeps_the_same_session_open_with_its_ref() {
1772 let (mut s, p) = store_with_project();
1776 let id = create(&mut s, p, TaskState::Ready);
1777 let sess = s
1778 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1779 .unwrap()
1780 .1;
1781 s.set_session_ref(sess.id, "ref-1").unwrap();
1782 s.apply(id, Action::Complete(None)).unwrap();
1783 s.apply(id, Action::RejectWork("redo the tests".into()))
1784 .unwrap();
1785
1786 let live = s.session(sess.id).unwrap();
1787 assert!(live.ended_at.is_none(), "reject leaves the session open");
1788 assert_eq!(live.session_ref.as_deref(), Some("ref-1"));
1789 assert_eq!(s.task(id).unwrap().state, TaskState::Running);
1790 }
1791
1792 mod waiting {
1795 use super::*;
1796
1797 #[test]
1798 fn hand_off_keeps_the_session_open_for_a_later_reject() {
1799 let (mut s, p) = store_with_project();
1803 let id = create(&mut s, p, TaskState::Ready);
1804 let sess = s
1805 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1806 .unwrap()
1807 .1;
1808 s.apply(id, Action::Complete(None)).unwrap();
1809
1810 let task = s.apply(id, Action::HandOff).unwrap();
1811 assert_eq!(task.state, TaskState::Waiting);
1812 assert!(
1813 s.session(sess.id).unwrap().ended_at.is_none(),
1814 "waiting keeps the session open"
1815 );
1816 }
1817
1818 #[test]
1819 fn hand_off_is_refused_from_states_other_than_review() {
1820 let (mut s, p) = store_with_project();
1823 for state in [
1824 TaskState::Proposed,
1825 TaskState::Parked,
1826 TaskState::Ready,
1827 TaskState::Running,
1828 TaskState::NeedsInput,
1829 TaskState::Stalled,
1830 ] {
1831 let id = task_in_state(&mut s, p, state);
1832 assert!(
1833 matches!(
1834 s.apply(id, Action::HandOff),
1835 Err(Error::InvalidTransition { .. })
1836 ),
1837 "hand off should be refused from {state}"
1838 );
1839 }
1840 }
1841
1842 #[test]
1843 fn accept_from_waiting_closes_the_session_completed() {
1844 let (mut s, p) = store_with_project();
1847 let id = create(&mut s, p, TaskState::Ready);
1848 let sess = s
1849 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1850 .unwrap()
1851 .1;
1852 s.apply(id, Action::Complete(None)).unwrap();
1853 s.apply(id, Action::HandOff).unwrap();
1854
1855 let task = s.apply(id, Action::Accept).unwrap();
1856 assert_eq!(task.state, TaskState::Done);
1857 assert!(task.closed_at.is_some());
1858 let closed = s.session(sess.id).unwrap();
1859 assert!(closed.ended_at.is_some());
1860 assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1861 }
1862
1863 #[test]
1864 fn abandon_from_waiting_closes_the_session_aborted() {
1865 let (mut s, p) = store_with_project();
1866 let id = create(&mut s, p, TaskState::Ready);
1867 let sess = s
1868 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1869 .unwrap()
1870 .1;
1871 s.apply(id, Action::Complete(None)).unwrap();
1872 s.apply(id, Action::HandOff).unwrap();
1873
1874 let task = s.apply(id, Action::Abandon).unwrap();
1875 assert_eq!(task.state, TaskState::Rejected);
1876 assert_eq!(
1877 s.session(sess.id).unwrap().outcome,
1878 Some(SessionOutcome::Aborted)
1879 );
1880 }
1881
1882 #[test]
1883 fn reclaim_pulls_the_work_back_to_review_keeping_the_session() {
1884 let (mut s, p) = store_with_project();
1885 let id = create(&mut s, p, TaskState::Ready);
1886 let sess = s
1887 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1888 .unwrap()
1889 .1;
1890 s.apply(id, Action::Complete(None)).unwrap();
1891 s.apply(id, Action::HandOff).unwrap();
1892
1893 let task = s.apply(id, Action::Reclaim).unwrap();
1894 assert_eq!(task.state, TaskState::Review);
1895 assert!(s.session(sess.id).unwrap().ended_at.is_none());
1896 }
1897
1898 #[test]
1899 fn reject_from_waiting_reuses_the_same_session_with_feedback() {
1900 let (mut s, p) = store_with_project();
1905 let id = create(&mut s, p, TaskState::Ready);
1906 let sess = s
1907 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1908 .unwrap()
1909 .1;
1910 s.set_session_ref(sess.id, "ref-1").unwrap();
1911 s.apply(id, Action::Complete(None)).unwrap();
1912 s.apply(id, Action::HandOff).unwrap();
1913
1914 let task = s
1915 .apply(id, Action::RejectWork("reviewer wants tests".into()))
1916 .unwrap();
1917 assert_eq!(task.state, TaskState::Running);
1918 assert!(task.body.contains("## Feedback"));
1919 assert!(task.body.contains("- reviewer wants tests"));
1920
1921 let live = s.session(sess.id).unwrap();
1922 assert!(live.ended_at.is_none(), "reject keeps the session open");
1923 assert_eq!(live.session_ref.as_deref(), Some("ref-1"));
1924 }
1925
1926 #[test]
1927 fn reconcile_leaves_a_waiting_session_untouched() {
1928 let (mut s, p) = store_with_project();
1931 let id = create(&mut s, p, TaskState::Ready);
1932 let sess = s
1933 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1934 .unwrap()
1935 .1;
1936 s.apply(id, Action::Complete(None)).unwrap();
1937 s.apply(id, Action::HandOff).unwrap();
1938
1939 assert!(
1940 s.reconcile_session(sess.id, false, false)
1941 .unwrap()
1942 .is_none()
1943 );
1944 assert!(s.session(sess.id).unwrap().ended_at.is_none());
1945 assert_eq!(s.task(id).unwrap().state, TaskState::Waiting);
1946 }
1947 }
1948
1949 #[test]
1950 fn a_second_open_session_violates_the_unique_index() {
1951 let (mut s, p) = store_with_project();
1954 let id = create(&mut s, p, TaskState::Ready);
1955 s.record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1956 .unwrap();
1957 let second = s.conn.execute(
1958 "INSERT INTO sessions (task_id, agent, started_at) VALUES (?1, 'x', datetime('now'))",
1959 [id],
1960 );
1961 assert!(second.is_err(), "a second open session must be rejected");
1962 }
1963
1964 #[test]
1965 fn terminal_states_are_final() {
1966 let (mut s, p) = store_with_project();
1967 for state in [TaskState::Done, TaskState::Rejected] {
1968 let id = task_in_state(&mut s, p, state);
1969 for action in all_actions() {
1970 assert!(s.apply(id, action).is_err(), "{state} must be terminal");
1971 }
1972 }
1973 }
1974
1975 mod reconcile {
1978 use super::*;
1979 use crate::model::SessionOutcome;
1980
1981 fn dispatch(s: &mut Store, p: i64) -> (i64, i64) {
1982 let task_id = create(s, p, TaskState::Ready);
1983 let (_, session) = s
1984 .record_dispatch(
1985 task_id,
1986 "claude",
1987 Some(4242),
1988 LivenessSource::Pid,
1989 Some("/var/log/s.log"),
1990 )
1991 .unwrap();
1992 (task_id, session.id)
1993 }
1994
1995 #[test]
1996 fn live_pid_is_left_untouched() {
1997 let (mut s, p) = store_with_project();
1998 let (task_id, session_id) = dispatch(&mut s, p);
1999
2000 let result = s.reconcile_session(session_id, true, false).unwrap();
2001 assert!(result.is_none());
2002 assert_eq!(s.task(task_id).unwrap().state, TaskState::Running);
2003 assert!(s.session(session_id).unwrap().ended_at.is_none());
2004 }
2005
2006 #[test]
2007 fn dead_pid_on_a_running_task_stalls_it() {
2008 let (mut s, p) = store_with_project();
2009 let (task_id, session_id) = dispatch(&mut s, p);
2010
2011 let (session, task) = s
2012 .reconcile_session(session_id, false, false)
2013 .unwrap()
2014 .unwrap();
2015 assert_eq!(session.outcome, Some(SessionOutcome::Failed));
2016 assert!(session.ended_at.is_some());
2017 assert_eq!(task.state, TaskState::Stalled);
2020
2021 let events = s.events_for(task_id).unwrap();
2022 let transition = events.last().unwrap();
2023 assert_eq!(transition.kind, "transition");
2024 assert_eq!(
2025 transition.detail.as_deref(),
2026 Some("running -> stalled"),
2027 "{events:?}"
2028 );
2029 let reconcile = &events[events.len() - 2];
2030 assert_eq!(reconcile.kind, "reconcile");
2031 let detail = reconcile.detail.as_deref().unwrap();
2032 assert!(detail.contains("without reporting"), "{detail}");
2033 assert!(detail.contains("failed"), "{detail}");
2034 }
2035
2036 #[test]
2041 fn dead_pid_on_a_refining_task_returns_it_to_proposed() {
2042 let (mut s, p) = store_with_project();
2043 let task_id = create(&mut s, p, TaskState::Proposed);
2044 let (_, session) = s
2045 .record_refine_launch(
2046 task_id,
2047 "thin body",
2048 "claude",
2049 Some(4242),
2050 LivenessSource::Pid,
2051 None,
2052 )
2053 .unwrap();
2054
2055 assert!(
2057 s.reconcile_session(session.id, true, false)
2058 .unwrap()
2059 .is_none()
2060 );
2061 assert_eq!(s.task(task_id).unwrap().state, TaskState::Refining);
2062
2063 let (session, task) = s
2064 .reconcile_session(session.id, false, false)
2065 .unwrap()
2066 .unwrap();
2067 assert_eq!(session.outcome, Some(SessionOutcome::Failed));
2068 assert!(session.ended_at.is_some());
2069 assert_eq!(task.state, TaskState::Proposed);
2070 assert!(s.refine_failed_flag(task_id).unwrap());
2071 assert!(!s.refined_flag(task_id).unwrap());
2072
2073 let events = s.events_for(task_id).unwrap();
2074 assert!(
2075 events.iter().any(|e| e.kind == "reconcile"
2076 && e.detail.as_deref().is_some_and(|d| d.contains("failed"))),
2077 "{events:?}"
2078 );
2079 assert!(
2080 events
2081 .iter()
2082 .any(|e| e.detail.as_deref() == Some("refining -> proposed")),
2083 "{events:?}"
2084 );
2085 }
2086
2087 #[test]
2088 fn dead_pid_reports_capped_when_the_caller_says_so() {
2089 let (mut s, p) = store_with_project();
2090 let (_, session_id) = dispatch(&mut s, p);
2091
2092 let (session, task) = s
2093 .reconcile_session(session_id, false, true)
2094 .unwrap()
2095 .unwrap();
2096 assert_eq!(session.outcome, Some(SessionOutcome::Capped));
2097 assert_eq!(task.state, TaskState::Stalled);
2100 }
2101
2102 #[test]
2103 fn a_stalled_task_can_be_redispatched() {
2104 let (mut s, p) = store_with_project();
2107 let (task_id, session_id) = dispatch(&mut s, p);
2108 s.reconcile_session(session_id, false, false).unwrap();
2109 assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
2110
2111 let (task, session) = s
2112 .record_dispatch(
2113 task_id,
2114 "codex",
2115 Some(4343),
2116 LivenessSource::Pid,
2117 Some("/var/log/s2.log"),
2118 )
2119 .unwrap();
2120 assert_eq!(task.state, TaskState::Running);
2121 assert_eq!(session.agent, "codex");
2122 assert!(session.ended_at.is_none());
2123 }
2124
2125 #[test]
2126 fn a_stalled_task_completes_to_review_on_the_dead_sessions_behalf() {
2127 let (mut s, p) = store_with_project();
2132 let (task_id, session_id) = dispatch(&mut s, p);
2133 s.reconcile_session(session_id, false, false).unwrap();
2134 assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
2135
2136 let task = s
2137 .apply(task_id, Action::Complete(Some("landed on feat/x".into())))
2138 .unwrap();
2139 assert_eq!(task.state, TaskState::Review);
2140
2141 let session = s.session(session_id).unwrap();
2142 assert!(session.ended_at.is_some());
2143 assert_eq!(session.outcome, Some(SessionOutcome::Failed));
2144 assert!(
2145 s.sessions_for(task_id)
2146 .unwrap()
2147 .iter()
2148 .all(|x| x.ended_at.is_some()),
2149 "completing a stall must not open a session"
2150 );
2151
2152 let events = s.events_for(task_id).unwrap();
2153 assert!(
2154 events
2155 .iter()
2156 .any(|e| e.kind == "transition"
2157 && e.detail.as_deref() == Some("stalled -> review")),
2158 "{events:?}"
2159 );
2160 assert!(
2161 events
2162 .iter()
2163 .any(|e| e.kind == "summary"
2164 && e.detail.as_deref() == Some("landed on feat/x")),
2165 "{events:?}"
2166 );
2167 }
2168
2169 #[test]
2170 fn a_stalled_task_with_an_open_blocker_is_parked() {
2171 let (mut s, p) = store_with_project();
2175 let (task_id, session_id) = dispatch(&mut s, p);
2176 let blocker = create(&mut s, p, TaskState::Ready);
2177 s.add_dep(task_id, blocker, crate::model::DepKind::Blocks)
2178 .unwrap();
2179
2180 let (_, task) = s
2181 .reconcile_session(session_id, false, false)
2182 .unwrap()
2183 .unwrap();
2184 assert_eq!(task.state, TaskState::Parked);
2185 }
2186
2187 #[test]
2188 fn adding_an_open_blocker_demotes_a_stalled_task() {
2189 let (mut s, p) = store_with_project();
2190 let (task_id, session_id) = dispatch(&mut s, p);
2191 s.reconcile_session(session_id, false, false).unwrap();
2192 assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
2193
2194 let blocker = create(&mut s, p, TaskState::Ready);
2195 s.add_dep(task_id, blocker, crate::model::DepKind::Blocks)
2196 .unwrap();
2197 assert_eq!(s.task(task_id).unwrap().state, TaskState::Parked);
2198
2199 s.apply(blocker, Action::Start).unwrap();
2202 s.apply(blocker, Action::Complete(None)).unwrap();
2203 s.apply(blocker, Action::Accept).unwrap();
2204 assert_eq!(s.task(task_id).unwrap().state, TaskState::Ready);
2205 }
2206
2207 #[test]
2208 fn a_needs_input_tasks_session_stays_open() {
2209 let (mut s, p) = store_with_project();
2213 let (task_id, session_id) = dispatch(&mut s, p);
2214 s.apply(task_id, Action::Ask("A or B?".into())).unwrap();
2215
2216 assert!(
2217 s.reconcile_session(session_id, false, false)
2218 .unwrap()
2219 .is_none()
2220 );
2221 assert!(s.session(session_id).unwrap().ended_at.is_none());
2222 assert_eq!(s.task(task_id).unwrap().state, TaskState::NeedsInput);
2223 }
2224
2225 #[test]
2226 fn a_review_tasks_session_stays_open() {
2227 let (mut s, p) = store_with_project();
2230 let (task_id, session_id) = dispatch(&mut s, p);
2231 s.apply(task_id, Action::Complete(None)).unwrap();
2232
2233 assert!(
2234 s.reconcile_session(session_id, false, false)
2235 .unwrap()
2236 .is_none()
2237 );
2238 assert!(s.session(session_id).unwrap().ended_at.is_none());
2239 assert_eq!(s.task(task_id).unwrap().state, TaskState::Review);
2240 }
2241
2242 #[test]
2243 fn aborting_closes_the_session_so_reconcile_is_a_noop() {
2244 let (mut s, p) = store_with_project();
2247 let (task_id, session_id) = dispatch(&mut s, p);
2248 s.apply(task_id, Action::Abort).unwrap();
2249
2250 let session = s.session(session_id).unwrap();
2251 assert_eq!(session.outcome, Some(SessionOutcome::Aborted));
2252 assert!(session.ended_at.is_some());
2253 assert!(
2254 s.reconcile_session(session_id, false, false)
2255 .unwrap()
2256 .is_none()
2257 );
2258 assert_eq!(s.task(task_id).unwrap().state, TaskState::Ready);
2261 }
2262
2263 #[test]
2268 fn the_closing_verdicts_hand_back_the_session_they_retired() {
2269 for (action, setup) in [
2270 (Action::Abort, Vec::new()),
2271 (Action::Accept, vec![Action::Complete(None)]),
2272 (Action::Abandon, vec![Action::Complete(None)]),
2273 ] {
2274 let (mut s, p) = store_with_project();
2275 let (task_id, session_id) = dispatch(&mut s, p);
2276 s.set_session_ref(session_id, "full-uuid-1").unwrap();
2277 for step in setup {
2278 s.apply(task_id, step).unwrap();
2279 }
2280
2281 let (_, stopped) = s.apply_closing(task_id, action.clone()).unwrap();
2282 let stopped = stopped.unwrap_or_else(|| panic!("{action:?} retires its session"));
2283 assert_eq!(stopped.id, session_id, "{action:?}");
2284 assert_eq!(stopped.session_ref.as_deref(), Some("full-uuid-1"));
2285 assert!(stopped.ended_at.is_some(), "{action:?}");
2286 }
2287 }
2288
2289 #[test]
2294 fn a_transition_that_keeps_its_session_retires_nothing() {
2295 for action in [
2296 Action::Ask("A or B?".into()),
2297 Action::Complete(None),
2298 Action::Park,
2299 ] {
2300 let (mut s, p) = store_with_project();
2301 let (task_id, session_id) = dispatch(&mut s, p);
2302 if action == Action::Park {
2303 s.reconcile_session(session_id, false, false).unwrap();
2306 }
2307 let (_, stopped) = s.apply_closing(task_id, action.clone()).unwrap();
2308 assert!(stopped.is_none(), "{action:?}");
2309 }
2310 }
2311
2312 #[test]
2316 fn concluding_a_refine_retires_nothing() {
2317 let (mut s, p) = store_with_project();
2318 let task_id = create(&mut s, p, TaskState::Proposed);
2319 s.record_refine_launch(
2320 task_id,
2321 "thin",
2322 "claude",
2323 Some(1),
2324 LivenessSource::Pid,
2325 None,
2326 )
2327 .unwrap();
2328
2329 let (_, stopped) = s
2330 .apply_closing(
2331 task_id,
2332 Action::ConcludeRefine(crate::model::RefineOutcome::Applied),
2333 )
2334 .unwrap();
2335 assert!(stopped.is_none());
2336 }
2337
2338 #[test]
2341 fn a_task_with_no_open_session_retires_nothing() {
2342 let (mut s, p) = store_with_project();
2343 let task_id = create(&mut s, p, TaskState::Ready);
2344 let (task, stopped) = s.apply_closing(task_id, Action::Abandon).unwrap();
2345 assert_eq!(task.state, TaskState::Rejected);
2346 assert!(stopped.is_none());
2347 }
2348
2349 #[test]
2350 fn a_stale_open_session_on_a_closed_task_is_finalised() {
2351 let (mut s, p) = store_with_project();
2355 let (task_id, session_id) = dispatch(&mut s, p);
2356 s.conn
2357 .execute(
2358 "UPDATE tasks SET state = 'done', closed_at = datetime('now') WHERE id = ?1",
2359 [task_id],
2360 )
2361 .unwrap();
2362
2363 let (session, task) = s
2364 .reconcile_session(session_id, false, false)
2365 .unwrap()
2366 .unwrap();
2367 assert!(session.ended_at.is_some());
2368 assert_eq!(session.outcome, Some(SessionOutcome::Completed));
2369 assert_eq!(task.state, TaskState::Done);
2370 }
2371
2372 #[test]
2373 fn an_already_ended_session_is_not_reprocessed() {
2374 let (mut s, p) = store_with_project();
2375 let (_, session_id) = dispatch(&mut s, p);
2376 s.reconcile_session(session_id, false, false).unwrap();
2377 let first_ended_at = s.session(session_id).unwrap().ended_at;
2378
2379 let result = s.reconcile_session(session_id, false, false).unwrap();
2380 assert!(result.is_none());
2381 assert_eq!(s.session(session_id).unwrap().ended_at, first_ended_at);
2382 }
2383
2384 #[test]
2385 fn redispatch_supersedes_the_prior_session_keeping_one_open() {
2386 let (mut s, p) = store_with_project();
2390 let (task_id, older_session) = dispatch(&mut s, p);
2391 s.apply(task_id, Action::Abort).unwrap();
2392 assert!(s.session(older_session).unwrap().ended_at.is_some());
2393
2394 let newer_session = s
2395 .record_dispatch(
2396 task_id,
2397 "claude",
2398 Some(4343),
2399 LivenessSource::Pid,
2400 Some("/var/log/s2.log"),
2401 )
2402 .unwrap()
2403 .1
2404 .id;
2405
2406 let open: Vec<i64> = s
2408 .sessions_for(task_id)
2409 .unwrap()
2410 .into_iter()
2411 .filter(|x| x.ended_at.is_none())
2412 .map(|x| x.id)
2413 .collect();
2414 assert_eq!(open, vec![newer_session]);
2415
2416 assert!(
2418 s.reconcile_session(older_session, false, false)
2419 .unwrap()
2420 .is_none()
2421 );
2422 assert_eq!(s.task(task_id).unwrap().state, TaskState::Running);
2423 assert!(s.session(newer_session).unwrap().ended_at.is_none());
2424 }
2425
2426 #[test]
2427 fn unknown_session_is_an_error() {
2428 let (mut s, _p) = store_with_project();
2429 assert!(matches!(
2430 s.reconcile_session(9999, false, false),
2431 Err(Error::SessionNotFound(9999))
2432 ));
2433 }
2434 }
2435
2436 #[test]
2437 fn add_dep_rejects_self_blocks() {
2438 let (mut s, p) = store_with_project();
2439 let task = create(&mut s, p, TaskState::Ready);
2440 let err = s.add_dep(task, task, DepKind::Blocks).unwrap_err();
2441 assert!(
2442 matches!(&err, Error::DependencyCycle(path) if path == &format!("{task} -> {task}")),
2443 "expected a self-cycle error, got {err}"
2444 );
2445 }
2446
2447 #[test]
2448 fn add_dep_rejects_direct_cycle() {
2449 let (mut s, p) = store_with_project();
2450 let a = create(&mut s, p, TaskState::Ready);
2451 let b = create(&mut s, p, TaskState::Ready);
2452 s.add_dep(b, a, DepKind::Blocks).unwrap();
2453
2454 let err = s.add_dep(a, b, DepKind::Blocks).unwrap_err();
2455 assert!(
2456 matches!(&err, Error::DependencyCycle(path) if path == &format!("{a} -> {b} -> {a}")),
2457 "expected a direct cycle error, got {err}"
2458 );
2459 assert!(s.deps_of(a).unwrap().is_empty());
2461 }
2462
2463 #[test]
2464 fn add_dep_rejects_transitive_cycle() {
2465 let (mut s, p) = store_with_project();
2466 let a = create(&mut s, p, TaskState::Ready);
2467 let b = create(&mut s, p, TaskState::Ready);
2468 let c = create(&mut s, p, TaskState::Ready);
2469 s.add_dep(b, c, DepKind::Blocks).unwrap();
2470 s.add_dep(c, a, DepKind::Blocks).unwrap();
2471
2472 let err = s.add_dep(a, b, DepKind::Blocks).unwrap_err();
2473 assert!(
2474 matches!(&err, Error::DependencyCycle(path)
2475 if path == &format!("{a} -> {b} -> {c} -> {a}")),
2476 "expected a transitive cycle error, got {err}"
2477 );
2478 }
2479
2480 #[test]
2481 fn add_dep_allows_a_diamond() {
2482 let (mut s, p) = store_with_project();
2484 let a = create(&mut s, p, TaskState::Ready);
2485 let b = create(&mut s, p, TaskState::Ready);
2486 let c = create(&mut s, p, TaskState::Ready);
2487 let d = create(&mut s, p, TaskState::Ready);
2488 s.add_dep(b, d, DepKind::Blocks).unwrap();
2489 s.add_dep(c, d, DepKind::Blocks).unwrap();
2490 s.add_dep(a, b, DepKind::Blocks).unwrap();
2491 s.add_dep(a, c, DepKind::Blocks).unwrap();
2492
2493 let deps = s.deps_of(a).unwrap();
2494 assert_eq!(
2495 deps.iter().map(|d| d.depends_on).collect::<Vec<_>>(),
2496 vec![b, c]
2497 );
2498 }
2499
2500 #[test]
2501 fn set_blocks_deps_rejects_self_and_transitive_cycles() {
2502 let (mut s, p) = store_with_project();
2503 let a = create(&mut s, p, TaskState::Ready);
2504 let b = create(&mut s, p, TaskState::Ready);
2505 let c = create(&mut s, p, TaskState::Ready);
2506
2507 let err = s.set_blocks_deps(a, &[a]).unwrap_err();
2508 assert!(matches!(&err, Error::DependencyCycle(path) if path == &format!("{a} -> {a}")));
2509
2510 s.set_blocks_deps(b, &[c]).unwrap();
2512 s.set_blocks_deps(c, &[a]).unwrap();
2513 let err = s.set_blocks_deps(a, &[b]).unwrap_err();
2514 assert!(matches!(&err, Error::DependencyCycle(path)
2515 if path == &format!("{a} -> {b} -> {c} -> {a}")));
2516 assert!(s.deps_of(a).unwrap().is_empty());
2518 }
2519
2520 #[test]
2521 fn set_blocks_deps_allows_a_diamond() {
2522 let (mut s, p) = store_with_project();
2523 let a = create(&mut s, p, TaskState::Ready);
2524 let b = create(&mut s, p, TaskState::Ready);
2525 let c = create(&mut s, p, TaskState::Ready);
2526 let d = create(&mut s, p, TaskState::Ready);
2527 s.set_blocks_deps(b, &[d]).unwrap();
2528 s.set_blocks_deps(c, &[d]).unwrap();
2529
2530 let t = s.set_blocks_deps(a, &[b, c]).unwrap();
2531 assert_eq!(t.state, TaskState::Parked);
2532 }
2533}