1use std::collections::{HashMap, VecDeque};
8
9use rusqlite::{Connection, params};
10
11use crate::error::{Error, Result};
12use crate::model::{Session, SessionOutcome, Task, TaskState};
13use crate::store::{
14 Store, close_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 Start,
33 Ask(String),
35 Answer(String),
37 Complete(Option<String>),
41 HandOff,
44 Reclaim,
47 Accept,
49 RejectWork(String),
52 Abort,
54 Park,
56 Unpark,
58 Abandon,
60}
61
62impl Action {
63 fn name(&self) -> &'static str {
64 match self {
65 Action::Triage(_) => "triage",
66 Action::Start => "start",
67 Action::Ask(_) => "ask",
68 Action::Answer(_) => "answer",
69 Action::Complete(_) => "complete",
70 Action::HandOff => "hand off",
71 Action::Reclaim => "reclaim",
72 Action::Accept => "accept",
73 Action::RejectWork(_) => "reject work on",
74 Action::Abort => "abort",
75 Action::Park => "park",
76 Action::Unpark => "unpark",
77 Action::Abandon => "abandon",
78 }
79 }
80}
81
82impl Store {
83 pub fn legal_actions(state: TaskState, human: bool) -> Vec<Action> {
90 use TaskState::*;
91 match state {
92 Proposed => vec![
93 Action::Triage(Triage::Ready),
94 Action::Triage(Triage::Parked),
95 Action::Triage(Triage::Reject),
96 ],
97 Parked => vec![Action::Unpark, Action::Abandon],
98 Ready => vec![Action::Start, Action::Park, Action::Abandon],
99 Running if human => vec![Action::Complete(None), Action::Abort],
100 Running => vec![
101 Action::Ask(String::new()),
102 Action::Complete(None),
103 Action::Abort,
104 ],
105 NeedsInput => vec![Action::Answer(String::new()), Action::Abandon],
106 Review => vec![
107 Action::Accept,
108 Action::RejectWork(String::new()),
109 Action::HandOff,
110 Action::Abandon,
111 ],
112 Waiting => vec![
113 Action::Accept,
114 Action::RejectWork(String::new()),
115 Action::Reclaim,
116 Action::Abandon,
117 ],
118 Stalled => vec![
119 Action::Start,
120 Action::Complete(None),
121 Action::Park,
122 Action::Abandon,
123 ],
124 Done | Rejected => vec![],
125 }
126 }
127
128 pub fn apply(&mut self, task_id: i64, action: Action) -> Result<Task> {
129 let tx = self.conn.transaction()?;
130 apply_action(&tx, task_id, action)?;
131 tx.commit()?;
132 self.task(task_id)
133 }
134
135 pub fn record_dispatch(
140 &mut self,
141 task_id: i64,
142 agent: &str,
143 pid: Option<i64>,
144 log_path: Option<&str>,
145 ) -> Result<(Task, Session)> {
146 let tx = self.conn.transaction()?;
147 reject_human_dispatch(&tx, task_id)?;
148 apply_action(&tx, task_id, Action::Start)?;
149 let session_id = insert_session(&tx, task_id, agent, pid, log_path)?;
150 tx.commit()?;
151 Ok((self.task(task_id)?, self.session(session_id)?))
152 }
153
154 pub fn record_continuation(
162 &mut self,
163 task_id: i64,
164 agent: &str,
165 pid: Option<i64>,
166 log_path: Option<&str>,
167 ) -> Result<(Task, Session)> {
168 let tx = self.conn.transaction()?;
169 let task = get_task(&tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
170 reject_human_dispatch(&tx, task_id)?;
171 if task.state != TaskState::Running {
172 return Err(Error::InvalidTransition {
173 from: task.state,
174 action: "continue".to_string(),
175 });
176 }
177 let session_id = insert_session(&tx, task_id, agent, pid, log_path)?;
178 tx.commit()?;
179 Ok((self.task(task_id)?, self.session(session_id)?))
180 }
181
182 pub fn reconcile_session(
203 &mut self,
204 session_id: i64,
205 pid_alive: bool,
206 likely_capped: bool,
207 ) -> Result<Option<(Session, Task)>> {
208 let tx = self.conn.transaction()?;
209 let session = get_session(&tx, session_id)?.ok_or(Error::SessionNotFound(session_id))?;
210 if session.ended_at.is_some() {
211 return Ok(None);
212 }
213 let task = get_task(&tx, session.task_id)?.ok_or(Error::TaskNotFound(session.task_id))?;
214
215 let outcome = match task.state {
216 TaskState::Running => {
217 if pid_alive {
218 return Ok(None);
219 }
220 if likely_capped {
221 SessionOutcome::Capped
222 } else {
223 SessionOutcome::Failed
224 }
225 }
226 TaskState::NeedsInput | TaskState::Review | TaskState::Waiting => return Ok(None),
230 TaskState::Done => SessionOutcome::Completed,
233 TaskState::Rejected
234 | TaskState::Ready
235 | TaskState::Parked
236 | TaskState::Proposed
237 | TaskState::Stalled => SessionOutcome::Aborted,
238 };
239 set_session_outcome(&tx, session_id, outcome)?;
240
241 if task.state == TaskState::Running {
242 log_event(
243 &tx,
244 task.id,
245 "reconcile",
246 Some(&format!(
247 "session {session_id} ended without reporting ({outcome})"
248 )),
249 )?;
250 tx.execute(
251 "UPDATE tasks SET state = ?1, state_since = datetime('now') WHERE id = ?2",
252 params![TaskState::Stalled, task.id],
253 )?;
254 log_event(
255 &tx,
256 task.id,
257 "transition",
258 Some(&format!("{} -> {}", task.state, TaskState::Stalled)),
259 )?;
260 reconcile_readiness(&tx, task.id)?;
261 }
262 tx.commit()?;
263 Ok(Some((
264 self.session(session_id)?,
265 self.task(session.task_id)?,
266 )))
267 }
268
269 pub fn set_blocks_deps(&mut self, task_id: i64, depends_on: &[i64]) -> Result<Task> {
273 let tx = self.conn.transaction()?;
274 if get_task(&tx, task_id)?.is_none() {
275 return Err(Error::TaskNotFound(task_id));
276 }
277 tx.execute(
278 "DELETE FROM deps WHERE task_id = ?1 AND kind = 'blocks'",
279 [task_id],
280 )?;
281 for dep in depends_on {
282 if get_task(&tx, *dep)?.is_none() {
283 return Err(Error::TaskNotFound(*dep));
284 }
285 reject_blocks_cycle(&tx, task_id, *dep)?;
286 tx.execute(
287 "INSERT OR IGNORE INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, 'blocks')",
288 params![task_id, dep],
289 )?;
290 }
291 reconcile_readiness(&tx, task_id)?;
292 tx.commit()?;
293 self.task(task_id)
294 }
295
296 pub fn block_tasks(
303 &mut self,
304 blocker_id: i64,
305 dependents: &[i64],
306 ) -> Result<Vec<(Task, TaskState)>> {
307 let tx = self.conn.transaction()?;
308 if get_task(&tx, blocker_id)?.is_none() {
309 return Err(Error::TaskNotFound(blocker_id));
310 }
311 let mut affected = Vec::with_capacity(dependents.len());
312 for dep in dependents {
313 let before = get_task(&tx, *dep)?.ok_or(Error::TaskNotFound(*dep))?.state;
314 reject_blocks_cycle(&tx, *dep, blocker_id)?;
315 tx.execute(
316 "INSERT OR IGNORE INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, 'blocks')",
317 params![dep, blocker_id],
318 )?;
319 reconcile_readiness(&tx, *dep)?;
320 affected.push((*dep, before));
321 }
322 tx.commit()?;
323 affected
324 .into_iter()
325 .map(|(id, before)| Ok((self.task(id)?, before)))
326 .collect()
327 }
328}
329
330fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result<TaskState> {
336 let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
337
338 use TaskState::*;
339 let to = match (task.state, &action) {
340 (Proposed, Action::Triage(Triage::Parked)) => Parked,
341 (Proposed, Action::Triage(Triage::Ready)) => Ready,
342 (Proposed, Action::Triage(Triage::Reject)) => Rejected,
343 (Ready | Stalled, Action::Start) => Running,
344 (Running, Action::Ask(_)) if task.human => {
347 return Err(Error::HumanTask {
348 id: task_id,
349 reason: "its executor is the human, who cannot be blocked on their own \
350 decision — file a follow-up task that blocks on this one instead"
351 .into(),
352 });
353 }
354 (Running, Action::Ask(_)) => NeedsInput,
355 (NeedsInput, Action::Answer(_)) => Running,
356 (Running | Stalled, Action::Complete(_)) if task.human => Done,
359 (Running | Stalled, Action::Complete(_)) => Review,
363 (Review, Action::HandOff) => Waiting,
367 (Waiting, Action::Reclaim) => Review,
368 (Review | Waiting, Action::Accept) => Done,
369 (Review | Waiting, Action::RejectWork(_)) => Running,
370 (Running, Action::Abort) => Ready,
371 (Ready | Stalled, Action::Park) => Parked,
372 (Parked, Action::Unpark) => Ready,
373 (Parked | Ready | NeedsInput | Review | Waiting | Stalled, Action::Abandon) => Rejected,
374 _ => {
375 return Err(Error::InvalidTransition {
376 from: task.state,
377 action: action.name().to_string(),
378 });
379 }
380 };
381
382 match &action {
383 Action::Ask(q) if q.trim().is_empty() => {
384 return Err(Error::Invalid("a question is required".into()));
385 }
386 Action::Answer(a) if a.trim().is_empty() => {
387 return Err(Error::Invalid("an answer is required".into()));
388 }
389 Action::RejectWork(f) if f.trim().is_empty() => {
390 return Err(Error::Invalid("rejection feedback is required".into()));
391 }
392 _ => {}
393 }
394 let question = match &action {
395 Action::Ask(q) => Some(q.trim().to_string()),
396 _ => None,
397 };
398
399 tx.execute(
400 "UPDATE tasks SET state = ?1, state_since = datetime('now'), question = ?2,
401 closed_at = CASE WHEN ?3 THEN datetime('now') ELSE closed_at END
402 WHERE id = ?4",
403 params![to, question, to.is_terminal(), task_id],
404 )?;
405 log_event(
406 tx,
407 task_id,
408 "transition",
409 Some(&format!("{} -> {}", task.state, to)),
410 )?;
411
412 match &action {
413 Action::Answer(a) => {
414 append_section(tx, task_id, "Answers", a.trim())?;
415 log_event(tx, task_id, "answer", Some(a.trim()))?;
416 }
417 Action::RejectWork(f) => {
418 append_section(tx, task_id, "Feedback", f.trim())?;
419 log_event(tx, task_id, "feedback", Some(f.trim()))?;
420 }
421 Action::Complete(Some(s)) if !s.trim().is_empty() => {
422 log_event(tx, task_id, "summary", Some(s.trim()))?;
423 }
424 _ => {}
425 }
426
427 match &action {
432 Action::Accept => {
433 close_open_session(tx, task_id, SessionOutcome::Completed)?;
434 }
435 Action::Complete(_) if to == TaskState::Done => {
439 close_open_session(tx, task_id, SessionOutcome::Completed)?;
440 }
441 Action::Abort | Action::Abandon => {
442 close_open_session(tx, task_id, SessionOutcome::Aborted)?;
443 }
444 _ => {}
445 }
446
447 if to.is_terminal() {
448 reconcile_dependants(tx, task_id)?;
449 } else if to == TaskState::Ready {
450 reconcile_readiness(tx, task_id)?;
454 }
455
456 Ok(to)
457}
458
459fn reject_human_dispatch(tx: &Connection, task_id: i64) -> Result<()> {
463 let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
464 if task.human {
465 return Err(Error::HumanTask {
466 id: task_id,
467 reason: "no agent can execute it — start it by hand instead".into(),
468 });
469 }
470 Ok(())
471}
472
473pub(crate) fn reject_blocks_cycle(conn: &Connection, from: i64, to: i64) -> Result<()> {
477 if let Some(cycle) = find_blocks_cycle(conn, from, to)? {
478 let path = cycle
479 .iter()
480 .map(i64::to_string)
481 .collect::<Vec<_>>()
482 .join(" -> ");
483 return Err(Error::DependencyCycle(path));
484 }
485 Ok(())
486}
487
488fn find_blocks_cycle(conn: &Connection, from: i64, to: i64) -> Result<Option<Vec<i64>>> {
495 if from == to {
496 return Ok(Some(vec![from, from]));
497 }
498
499 let mut predecessor: HashMap<i64, i64> = HashMap::new();
502 let mut queue = VecDeque::new();
503 queue.push_back(to);
504
505 while let Some(node) = queue.pop_front() {
506 if node == from {
507 let mut path = vec![node];
508 let mut cur = node;
509 while cur != to {
510 cur = predecessor[&cur];
511 path.push(cur);
512 }
513 path.reverse(); let mut cycle = vec![from];
515 cycle.extend(path);
516 return Ok(Some(cycle));
517 }
518 let mut stmt = conn
519 .prepare_cached("SELECT depends_on FROM deps WHERE task_id = ?1 AND kind = 'blocks'")?;
520 let children: Vec<i64> = stmt
521 .query_map([node], |r| r.get(0))?
522 .collect::<rusqlite::Result<_>>()?;
523 for child in children {
524 if child != to && !predecessor.contains_key(&child) {
525 predecessor.insert(child, node);
526 queue.push_back(child);
527 }
528 }
529 }
530 Ok(None)
531}
532
533fn append_section(conn: &Connection, task_id: i64, heading: &str, text: &str) -> Result<()> {
536 let body: String = conn.query_row("SELECT body FROM tasks WHERE id = ?1", [task_id], |r| {
537 r.get(0)
538 })?;
539 let marker = format!("## {heading}");
540 let mut body = body.trim_end().to_string();
541 if !body.ends_with(&marker) && !body.contains(&format!("{marker}\n")) {
542 if !body.is_empty() {
543 body.push_str("\n\n");
544 }
545 body.push_str(&marker);
546 body.push('\n');
547 }
548 body.push_str(&format!("\n- {text}\n"));
549 conn.execute(
550 "UPDATE tasks SET body = ?1 WHERE id = ?2",
551 params![body, task_id],
552 )?;
553 Ok(())
554}
555
556fn reconcile_dependants(conn: &Connection, closed_id: i64) -> Result<()> {
560 let mut stmt =
561 conn.prepare("SELECT task_id FROM deps WHERE depends_on = ?1 AND kind = 'blocks'")?;
562 let dependants: Vec<i64> = stmt
563 .query_map([closed_id], |r| r.get(0))?
564 .collect::<rusqlite::Result<_>>()?;
565 for id in dependants {
566 reconcile_readiness(conn, id)?;
567 }
568 Ok(())
569}
570
571pub(crate) fn reconcile_readiness(conn: &Connection, task_id: i64) -> Result<()> {
578 let Some(task) = get_task(conn, task_id)? else {
579 return Ok(());
580 };
581 let (total, open): (i64, i64) = conn.query_row(
582 "SELECT COUNT(*),
583 COUNT(*) FILTER (WHERE b.state NOT IN ('done','rejected'))
584 FROM deps d JOIN tasks b ON b.id = d.depends_on
585 WHERE d.task_id = ?1 AND d.kind = 'blocks'",
586 [task_id],
587 |r| Ok((r.get(0)?, r.get(1)?)),
588 )?;
589
590 let to = match task.state {
591 TaskState::Parked if total > 0 && open == 0 => TaskState::Ready,
592 TaskState::Ready | TaskState::Stalled if open > 0 => TaskState::Parked,
593 _ => return Ok(()),
594 };
595 conn.execute(
596 "UPDATE tasks SET state = ?1, state_since = datetime('now') WHERE id = ?2",
597 params![to, task_id],
598 )?;
599 let reason = if to == TaskState::Ready {
600 "unblocked"
601 } else {
602 "blocked"
603 };
604 log_event(
605 conn,
606 task_id,
607 "transition",
608 Some(&format!("{} -> {} ({reason})", task.state, to)),
609 )?;
610 Ok(())
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use crate::model::{DepKind, Priority};
617 use crate::store::NewTask;
618
619 fn store_with_project() -> (Store, i64) {
620 let mut s = Store::open_in_memory().unwrap();
621 let p = s.create_project("proj", "/tmp/proj").unwrap();
622 (s, p.id)
623 }
624
625 fn create(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
626 s.create_task(NewTask {
627 project_id,
628 title: format!("task in {state}"),
629 body: String::new(),
630 priority: Priority::P1,
631 state,
632 agent: None,
633 human: false,
634 })
635 .unwrap()
636 .id
637 }
638
639 fn task_in_state(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
641 use TaskState::*;
642 match state {
643 Proposed | Parked | Ready => create(s, project_id, state),
644 Running => {
645 let id = create(s, project_id, Ready);
646 s.apply(id, Action::Start).unwrap();
647 id
648 }
649 NeedsInput => {
650 let id = task_in_state(s, project_id, Running);
651 s.apply(id, Action::Ask("which schema?".into())).unwrap();
652 id
653 }
654 Review => {
655 let id = task_in_state(s, project_id, Running);
656 s.apply(id, Action::Complete(None)).unwrap();
657 id
658 }
659 Waiting => {
660 let id = task_in_state(s, project_id, Review);
661 s.apply(id, Action::HandOff).unwrap();
662 id
663 }
664 Stalled => {
665 let id = create(s, project_id, Ready);
666 let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap();
667 s.reconcile_session(session.id, false, false).unwrap();
668 id
669 }
670 Done => {
671 let id = task_in_state(s, project_id, Review);
672 s.apply(id, Action::Accept).unwrap();
673 id
674 }
675 Rejected => {
676 let id = create(s, project_id, Proposed);
677 s.apply(id, Action::Triage(Triage::Reject)).unwrap();
678 id
679 }
680 }
681 }
682
683 fn all_actions() -> Vec<Action> {
684 vec![
685 Action::Triage(Triage::Parked),
686 Action::Triage(Triage::Ready),
687 Action::Triage(Triage::Reject),
688 Action::Start,
689 Action::Ask("q?".into()),
690 Action::Answer("a.".into()),
691 Action::Complete(None),
692 Action::HandOff,
693 Action::Reclaim,
694 Action::Accept,
695 Action::RejectWork("redo".into()),
696 Action::Abort,
697 Action::Park,
698 Action::Unpark,
699 Action::Abandon,
700 ]
701 }
702
703 fn expected(state: TaskState, action: &Action) -> Option<TaskState> {
706 use TaskState::*;
707 match (state, action) {
708 (Proposed, Action::Triage(Triage::Parked)) => Some(Parked),
709 (Proposed, Action::Triage(Triage::Ready)) => Some(Ready),
710 (Proposed, Action::Triage(Triage::Reject)) => Some(Rejected),
711 (Ready | Stalled, Action::Start) => Some(Running),
712 (Ready | Stalled, Action::Park) => Some(Parked),
713 (Parked, Action::Unpark) => Some(Ready),
714 (Running, Action::Ask(_)) => Some(NeedsInput),
715 (Running | Stalled, Action::Complete(_)) => Some(Review),
716 (Running, Action::Abort) => Some(Ready),
717 (NeedsInput, Action::Answer(_)) => Some(Running),
718 (Review, Action::HandOff) => Some(Waiting),
719 (Waiting, Action::Reclaim) => Some(Review),
720 (Review | Waiting, Action::Accept) => Some(Done),
721 (Review | Waiting, Action::RejectWork(_)) => Some(Running),
722 (Parked | Ready | NeedsInput | Review | Waiting | Stalled, Action::Abandon) => {
723 Some(Rejected)
724 }
725 _ => None,
726 }
727 }
728
729 #[test]
730 fn full_transition_matrix() {
731 for state in TaskState::ALL {
732 for action in all_actions() {
733 let (mut s, p) = store_with_project();
734 let id = task_in_state(&mut s, p, state);
735 let result = s.apply(id, action.clone());
736 match expected(state, &action) {
737 Some(to) => {
738 let task = result.unwrap_or_else(|e| {
739 panic!("{state} + {action:?} should reach {to}: {e}")
740 });
741 assert_eq!(task.state, to, "{state} + {action:?}");
742 }
743 None => {
744 assert!(
745 matches!(result, Err(Error::InvalidTransition { .. })),
746 "{state} + {action:?} should be rejected"
747 );
748 }
749 }
750 }
751 }
752 }
753
754 #[test]
755 fn legal_actions_agrees_with_apply() {
756 for state in TaskState::ALL {
757 let legal = Store::legal_actions(state, false);
758 for action in all_actions() {
759 let in_legal = legal
760 .iter()
761 .any(|l| std::mem::discriminant(l) == std::mem::discriminant(&action))
762 && match (&action, state) {
763 (Action::Triage(_), s) => s == TaskState::Proposed,
766 _ => true,
767 };
768 assert_eq!(
769 expected(state, &action).is_some(),
770 in_legal,
771 "legal_actions disagrees for {state} + {action:?}"
772 );
773 }
774 }
775 }
776
777 mod human {
780 use super::*;
781
782 fn create_human(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
783 s.create_task(NewTask {
784 project_id,
785 title: format!("human task in {state}"),
786 body: String::new(),
787 priority: Priority::P1,
788 state,
789 agent: None,
790 human: true,
791 })
792 .unwrap()
793 .id
794 }
795
796 #[test]
797 fn completion_goes_straight_to_done() {
798 let (mut s, p) = store_with_project();
799 let id = create_human(&mut s, p, TaskState::Ready);
800 s.apply(id, Action::Start).unwrap();
801
802 let task = s
803 .apply(id, Action::Complete(Some("bag captured".into())))
804 .unwrap();
805 assert_eq!(task.state, TaskState::Done);
806 assert!(task.closed_at.is_some());
807
808 let events = s.events_for(id).unwrap();
809 assert!(
810 events
811 .iter()
812 .any(|e| e.detail.as_deref() == Some("running -> done")),
813 "{events:?}"
814 );
815 assert!(
817 events
818 .iter()
819 .any(|e| e.kind == "summary" && e.detail.as_deref() == Some("bag captured"))
820 );
821 }
822
823 #[test]
824 fn ask_is_refused_and_writes_nothing() {
825 let (mut s, p) = store_with_project();
826 let id = create_human(&mut s, p, TaskState::Ready);
827 s.apply(id, Action::Start).unwrap();
828
829 let err = s.apply(id, Action::Ask("which bag?".into())).unwrap_err();
830 assert!(
831 matches!(err, Error::HumanTask { id: e, .. } if e == id),
832 "expected a human-only refusal, got {err}"
833 );
834 let task = s.task(id).unwrap();
835 assert_eq!(task.state, TaskState::Running);
836 assert!(task.question.is_none());
837 }
838
839 #[test]
840 fn record_dispatch_is_refused_and_writes_nothing() {
841 let (mut s, p) = store_with_project();
842 let id = create_human(&mut s, p, TaskState::Ready);
843
844 let err = s.record_dispatch(id, "claude", Some(1), None).unwrap_err();
845 assert!(
846 matches!(err, Error::HumanTask { id: e, .. } if e == id),
847 "expected a human-only refusal, got {err}"
848 );
849 assert_eq!(s.task(id).unwrap().state, TaskState::Ready);
850 assert!(s.sessions_for(id).unwrap().is_empty());
851 }
852
853 #[test]
854 fn record_continuation_is_refused() {
855 let (mut s, p) = store_with_project();
856 let id = create_human(&mut s, p, TaskState::Ready);
857 s.apply(id, Action::Start).unwrap();
858
859 let err = s.record_continuation(id, "claude", None, None).unwrap_err();
860 assert!(
861 matches!(err, Error::HumanTask { id: e, .. } if e == id),
862 "expected a human-only refusal, got {err}"
863 );
864 assert!(s.sessions_for(id).unwrap().is_empty());
865 }
866
867 #[test]
868 fn completion_unblocks_dependants() {
869 let (mut s, p) = store_with_project();
872 let blocker = create_human(&mut s, p, TaskState::Ready);
873 let dependant = create(&mut s, p, TaskState::Parked);
874 s.add_dep(dependant, blocker, DepKind::Blocks).unwrap();
875
876 s.apply(blocker, Action::Start).unwrap();
877 s.apply(blocker, Action::Complete(None)).unwrap();
878 assert_eq!(s.task(dependant).unwrap().state, TaskState::Ready);
879 }
880
881 #[test]
882 fn completion_closes_a_stray_open_session() {
883 let (mut s, p) = store_with_project();
887 let id = create_human(&mut s, p, TaskState::Ready);
888 s.apply(id, Action::Start).unwrap();
889 let stray = s.create_session(id, "claude", Some(1), None).unwrap();
890
891 s.apply(id, Action::Complete(None)).unwrap();
892 let closed = s.session(stray.id).unwrap();
893 assert!(closed.ended_at.is_some());
894 assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
895 }
896
897 #[test]
898 fn legal_actions_omit_ask_and_agree_with_apply() {
899 let legal = Store::legal_actions(TaskState::Running, true);
900 assert_eq!(legal, vec![Action::Complete(None), Action::Abort]);
901 for state in TaskState::ALL {
903 if state != TaskState::Running {
904 assert_eq!(
905 Store::legal_actions(state, true),
906 Store::legal_actions(state, false),
907 "{state}"
908 );
909 }
910 }
911 }
912
913 #[test]
916 fn full_transition_matrix_for_human_tasks() {
917 use TaskState::*;
918 for state in [Proposed, Parked, Ready, Running] {
919 for action in all_actions() {
920 let (mut s, p) = store_with_project();
921 let id = create_human(&mut s, p, if state == Running { Ready } else { state });
922 if state == Running {
923 s.apply(id, Action::Start).unwrap();
924 }
925 let result = s.apply(id, action.clone());
926 let expected = match (state, &action) {
927 (Running, Action::Ask(_)) => None,
929 (Running, Action::Complete(_)) => Some(Done),
930 _ => expected(state, &action),
931 };
932 match expected {
933 Some(to) => {
934 let task = result.unwrap_or_else(|e| {
935 panic!("human {state} + {action:?} should reach {to}: {e}")
936 });
937 assert_eq!(task.state, to, "human {state} + {action:?}");
938 }
939 None => {
940 assert!(
941 matches!(
942 result,
943 Err(Error::InvalidTransition { .. } | Error::HumanTask { .. })
944 ),
945 "human {state} + {action:?} should be rejected"
946 );
947 }
948 }
949 }
950 }
951 }
952 }
953
954 #[test]
955 fn transitions_restamp_state_since_and_log_events() {
956 let (mut s, p) = store_with_project();
957 let id = create(&mut s, p, TaskState::Ready);
958 s.conn
959 .execute(
960 "UPDATE tasks SET state_since = '2000-01-01 00:00:00' WHERE id = ?1",
961 [id],
962 )
963 .unwrap();
964 let task = s.apply(id, Action::Start).unwrap();
965 assert_ne!(task.state_since, "2000-01-01 00:00:00");
966 let events = s.events_for(id).unwrap();
967 assert_eq!(events.last().unwrap().kind, "transition");
968 assert_eq!(
969 events.last().unwrap().detail.as_deref(),
970 Some("ready -> running")
971 );
972 }
973
974 #[test]
975 fn question_is_set_iff_needs_input() {
976 let (mut s, p) = store_with_project();
977 let id = task_in_state(&mut s, p, TaskState::Running);
978 let task = s.apply(id, Action::Ask(" A or B? ".into())).unwrap();
979 assert_eq!(task.question.as_deref(), Some("A or B?"));
980 let task = s.apply(id, Action::Answer("B".into())).unwrap();
981 assert_eq!(task.state, TaskState::Running);
982 assert!(task.question.is_none());
983
984 let id = task_in_state(&mut s, p, TaskState::NeedsInput);
985 let task = s.apply(id, Action::Abandon).unwrap();
986 assert!(task.question.is_none());
987 }
988
989 #[test]
990 fn empty_question_answer_feedback_are_rejected() {
991 let (mut s, p) = store_with_project();
992 let running = task_in_state(&mut s, p, TaskState::Running);
993 assert!(s.apply(running, Action::Ask(" ".into())).is_err());
994 let waiting = task_in_state(&mut s, p, TaskState::NeedsInput);
995 assert!(s.apply(waiting, Action::Answer("".into())).is_err());
996 let review = task_in_state(&mut s, p, TaskState::Review);
997 assert!(s.apply(review, Action::RejectWork(" ".into())).is_err());
998 assert_eq!(s.task(waiting).unwrap().state, TaskState::NeedsInput);
1000 }
1001
1002 #[test]
1003 fn answers_and_feedback_accumulate_in_body() {
1004 let (mut s, p) = store_with_project();
1005 let id = task_in_state(&mut s, p, TaskState::NeedsInput);
1006 let task = s.apply(id, Action::Answer("Schema B".into())).unwrap();
1007 assert!(task.body.contains("## Answers"));
1008 assert!(task.body.contains("- Schema B"));
1009
1010 s.apply(id, Action::Ask("and the index?".into())).unwrap();
1011 let task = s.apply(id, Action::Answer("covering".into())).unwrap();
1012 assert_eq!(task.body.matches("## Answers").count(), 1);
1013 assert!(task.body.contains("- covering"));
1014
1015 s.apply(id, Action::Complete(None)).unwrap();
1016 let task = s
1017 .apply(id, Action::RejectWork("tests missing".into()))
1018 .unwrap();
1019 assert!(task.body.contains("## Feedback"));
1020 assert!(task.body.contains("- tests missing"));
1021 assert_eq!(
1022 s.events_for(id)
1023 .unwrap()
1024 .iter()
1025 .filter(|e| e.kind == "answer")
1026 .count(),
1027 2
1028 );
1029 }
1030
1031 #[test]
1032 fn complete_summary_is_logged_as_an_event() {
1033 let (mut s, p) = store_with_project();
1034 let id = task_in_state(&mut s, p, TaskState::Running);
1035 s.apply(
1036 id,
1037 Action::Complete(Some("Implemented X, tests pass".into())),
1038 )
1039 .unwrap();
1040 let summaries: Vec<_> = s
1041 .events_for(id)
1042 .unwrap()
1043 .into_iter()
1044 .filter(|e| e.kind == "summary")
1045 .collect();
1046 assert_eq!(summaries.len(), 1);
1047 assert_eq!(
1048 summaries[0].detail.as_deref(),
1049 Some("Implemented X, tests pass")
1050 );
1051 }
1052
1053 #[test]
1054 fn complete_without_summary_logs_no_summary_event() {
1055 let (mut s, p) = store_with_project();
1056 let id = task_in_state(&mut s, p, TaskState::Running);
1057 s.apply(id, Action::Complete(None)).unwrap();
1058 let blank = task_in_state(&mut s, p, TaskState::Running);
1059 s.apply(blank, Action::Complete(Some(" ".into())))
1060 .unwrap();
1061 assert!(
1062 s.events_for(id)
1063 .unwrap()
1064 .iter()
1065 .all(|e| e.kind != "summary")
1066 );
1067 assert!(
1068 s.events_for(blank)
1069 .unwrap()
1070 .iter()
1071 .all(|e| e.kind != "summary")
1072 );
1073 }
1074
1075 #[test]
1076 fn closing_stamps_closed_at() {
1077 let (mut s, p) = store_with_project();
1078 let id = task_in_state(&mut s, p, TaskState::Done);
1079 assert!(s.task(id).unwrap().closed_at.is_some());
1080 let id = task_in_state(&mut s, p, TaskState::Rejected);
1081 assert!(s.task(id).unwrap().closed_at.is_some());
1082 let id = task_in_state(&mut s, p, TaskState::Review);
1083 assert!(s.task(id).unwrap().closed_at.is_none());
1084 }
1085
1086 #[test]
1087 fn accepting_last_blocker_promotes_dependant() {
1088 let (mut s, p) = store_with_project();
1089 let blocker = task_in_state(&mut s, p, TaskState::Review);
1090 let dependant = create(&mut s, p, TaskState::Parked);
1091 s.add_dep(dependant, blocker, DepKind::Blocks).unwrap();
1092
1093 s.apply(blocker, Action::Accept).unwrap();
1094 let task = s.task(dependant).unwrap();
1095 assert_eq!(task.state, TaskState::Ready);
1096 let events = s.events_for(dependant).unwrap();
1097 assert_eq!(
1098 events.last().unwrap().detail.as_deref(),
1099 Some("parked -> ready (unblocked)")
1100 );
1101 }
1102
1103 #[test]
1104 fn promotion_waits_for_the_last_blocker() {
1105 let (mut s, p) = store_with_project();
1106 let b1 = task_in_state(&mut s, p, TaskState::Review);
1107 let b2 = create(&mut s, p, TaskState::Ready);
1108 let dependant = create(&mut s, p, TaskState::Parked);
1109 s.add_dep(dependant, b1, DepKind::Blocks).unwrap();
1110 s.add_dep(dependant, b2, DepKind::Blocks).unwrap();
1111
1112 s.apply(b1, Action::Accept).unwrap();
1113 assert_eq!(s.task(dependant).unwrap().state, TaskState::Parked);
1114
1115 s.apply(b2, Action::Abandon).unwrap();
1117 assert_eq!(s.task(dependant).unwrap().state, TaskState::Ready);
1118 }
1119
1120 #[test]
1121 fn parked_task_without_blockers_is_never_auto_promoted() {
1122 let (mut s, p) = store_with_project();
1123 let parked = create(&mut s, p, TaskState::Parked);
1124 let unrelated = task_in_state(&mut s, p, TaskState::Review);
1125 s.apply(unrelated, Action::Accept).unwrap();
1126 assert_eq!(s.task(parked).unwrap().state, TaskState::Parked);
1127 }
1128
1129 #[test]
1130 fn only_blocks_deps_gate_readiness() {
1131 let (mut s, p) = store_with_project();
1132 let other = create(&mut s, p, TaskState::Ready);
1133 let task = create(&mut s, p, TaskState::Ready);
1134 for kind in [DepKind::DiscoveredFrom, DepKind::Parent, DepKind::Related] {
1135 s.add_dep(task, other, kind).unwrap_or_default();
1136 }
1137 assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1138 }
1139
1140 #[test]
1141 fn adding_open_blocker_demotes_ready_task() {
1142 let (mut s, p) = store_with_project();
1143 let blocker = create(&mut s, p, TaskState::Ready);
1144 let task = create(&mut s, p, TaskState::Ready);
1145 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1146 let demoted = s.task(task).unwrap();
1147 assert_eq!(demoted.state, TaskState::Parked);
1148
1149 s.apply(blocker, Action::Start).unwrap();
1151 s.apply(blocker, Action::Complete(None)).unwrap();
1152 s.apply(blocker, Action::Accept).unwrap();
1153 assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1154 }
1155
1156 #[test]
1157 fn triaging_to_ready_parks_a_blocked_task() {
1158 let (mut s, p) = store_with_project();
1159 let blocker = create(&mut s, p, TaskState::Ready);
1160 let task = create(&mut s, p, TaskState::Proposed);
1161 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1162
1163 let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1166 assert_eq!(triaged.state, TaskState::Parked);
1167
1168 s.apply(blocker, Action::Start).unwrap();
1170 s.apply(blocker, Action::Complete(None)).unwrap();
1171 s.apply(blocker, Action::Accept).unwrap();
1172 assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1173 }
1174
1175 #[test]
1176 fn triaging_to_ready_stays_ready_when_unblocked() {
1177 let (mut s, p) = store_with_project();
1178 let closed = task_in_state(&mut s, p, TaskState::Done);
1179 let task = create(&mut s, p, TaskState::Proposed);
1180 s.add_dep(task, closed, DepKind::Blocks).unwrap();
1181 let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1183 assert_eq!(triaged.state, TaskState::Ready);
1184 }
1185
1186 #[test]
1187 fn aborting_parks_a_task_blocked_while_running() {
1188 let (mut s, p) = store_with_project();
1189 let task = task_in_state(&mut s, p, TaskState::Running);
1190 let blocker = create(&mut s, p, TaskState::Ready);
1191 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1193 assert_eq!(s.task(task).unwrap().state, TaskState::Running);
1194 let aborted = s.apply(task, Action::Abort).unwrap();
1196 assert_eq!(aborted.state, TaskState::Parked);
1197 }
1198
1199 #[test]
1200 fn unparking_a_blocked_task_reparks_it() {
1201 let (mut s, p) = store_with_project();
1202 let blocker = create(&mut s, p, TaskState::Ready);
1203 let task = create(&mut s, p, TaskState::Ready);
1204 s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1205 assert_eq!(s.task(task).unwrap().state, TaskState::Parked);
1206 let unparked = s.apply(task, Action::Unpark).unwrap();
1208 assert_eq!(unparked.state, TaskState::Parked);
1209 }
1210
1211 #[test]
1212 fn set_blocks_deps_replaces_and_reconciles() {
1213 let (mut s, p) = store_with_project();
1214 let open = create(&mut s, p, TaskState::Ready);
1215 let closed = task_in_state(&mut s, p, TaskState::Done);
1216 let task = create(&mut s, p, TaskState::Ready);
1217
1218 let t = s.set_blocks_deps(task, &[open, closed]).unwrap();
1219 assert_eq!(t.state, TaskState::Parked);
1220
1221 let t = s.set_blocks_deps(task, &[closed]).unwrap();
1223 assert_eq!(t.state, TaskState::Ready);
1224
1225 assert!(s.set_blocks_deps(task, &[task]).is_err());
1226 assert!(s.set_blocks_deps(task, &[9999]).is_err());
1227 }
1228
1229 #[test]
1230 fn block_tasks_demotes_ready_dependents_in_the_same_write() {
1231 let (mut s, p) = store_with_project();
1232 let blocker = create(&mut s, p, TaskState::Ready);
1233 let ready = create(&mut s, p, TaskState::Ready);
1234 let parked = create(&mut s, p, TaskState::Parked);
1235
1236 let affected = s.block_tasks(blocker, &[ready, parked]).unwrap();
1237 let states: Vec<_> = affected
1238 .iter()
1239 .map(|(t, before)| (t.id, *before, t.state))
1240 .collect();
1241 assert_eq!(
1242 states,
1243 vec![
1244 (ready, TaskState::Ready, TaskState::Parked),
1245 (parked, TaskState::Parked, TaskState::Parked),
1246 ]
1247 );
1248
1249 s.apply(blocker, Action::Start).unwrap();
1251 s.apply(blocker, Action::Complete(None)).unwrap();
1252 s.apply(blocker, Action::Accept).unwrap();
1253 assert_eq!(s.task(ready).unwrap().state, TaskState::Ready);
1254 assert_eq!(s.task(parked).unwrap().state, TaskState::Ready);
1255 }
1256
1257 #[test]
1258 fn block_tasks_is_additive_and_idempotent() {
1259 let (mut s, p) = store_with_project();
1260 let existing = create(&mut s, p, TaskState::Ready);
1261 let blocker = create(&mut s, p, TaskState::Ready);
1262 let task = create(&mut s, p, TaskState::Ready);
1263 s.set_blocks_deps(task, &[existing]).unwrap();
1264
1265 s.block_tasks(blocker, &[task]).unwrap();
1266 s.block_tasks(blocker, &[task]).unwrap();
1267 let deps = s.deps_of(task).unwrap();
1268 assert_eq!(deps.len(), 2, "{deps:?}");
1269 }
1270
1271 #[test]
1272 fn block_tasks_rejects_cycles_and_unknown_tasks() {
1273 let (mut s, p) = store_with_project();
1274 let a = create(&mut s, p, TaskState::Ready);
1275 let b = create(&mut s, p, TaskState::Ready);
1276 let c = create(&mut s, p, TaskState::Ready);
1277 s.set_blocks_deps(b, &[a]).unwrap();
1279 s.set_blocks_deps(c, &[b]).unwrap();
1280 let err = s.block_tasks(c, &[a]).unwrap_err();
1281 assert!(matches!(err, Error::DependencyCycle(_)), "{err:?}");
1282
1283 assert!(s.block_tasks(a, &[a]).is_err());
1285 assert!(s.block_tasks(a, &[9999]).is_err());
1286 assert!(s.block_tasks(9999, &[a]).is_err());
1287 assert!(s.deps_of(a).unwrap().is_empty());
1288 }
1289
1290 #[test]
1291 fn record_dispatch_starts_the_task_and_opens_a_session() {
1292 let (mut s, p) = store_with_project();
1293 let id = create(&mut s, p, TaskState::Ready);
1294 let (task, session) = s
1295 .record_dispatch(id, "claude", Some(4321), Some("/var/log/26.log"))
1296 .unwrap();
1297 assert_eq!(task.state, TaskState::Running);
1298 assert_eq!(session.task_id, id);
1299 assert_eq!(session.agent, "claude");
1300 assert_eq!(session.pid, Some(4321));
1301 assert_eq!(session.log_path.as_deref(), Some("/var/log/26.log"));
1302 assert!(session.ended_at.is_none());
1303 assert_eq!(s.sessions_for(id).unwrap().len(), 1);
1304 }
1305
1306 #[test]
1307 fn record_dispatch_on_a_non_ready_task_writes_nothing() {
1308 let (mut s, p) = store_with_project();
1309 let id = create(&mut s, p, TaskState::Proposed);
1310 assert!(matches!(
1311 s.record_dispatch(id, "claude", None, None),
1312 Err(Error::InvalidTransition { .. })
1313 ));
1314 assert_eq!(s.task(id).unwrap().state, TaskState::Proposed);
1316 assert!(s.sessions_for(id).unwrap().is_empty());
1317 }
1318
1319 #[test]
1322 fn record_continuation_adds_a_session_without_touching_state() {
1323 let (mut s, p) = store_with_project();
1324 let id = task_in_state(&mut s, p, TaskState::NeedsInput);
1325 s.apply(id, Action::Answer("B, with a covering index".into()))
1326 .unwrap();
1327 assert_eq!(s.task(id).unwrap().state, TaskState::Running);
1328
1329 let (task, session) = s
1330 .record_continuation(id, "claude", Some(777), Some("/var/log/31.log"))
1331 .unwrap();
1332 assert_eq!(task.state, TaskState::Running);
1333 assert_eq!(session.task_id, id);
1334 assert_eq!(session.agent, "claude");
1335 assert_eq!(session.pid, Some(777));
1336 assert_eq!(session.log_path.as_deref(), Some("/var/log/31.log"));
1337 assert!(session.ended_at.is_none());
1338 assert_eq!(s.sessions_for(id).unwrap().len(), 1);
1339 }
1340
1341 #[test]
1342 fn record_continuation_refuses_a_task_that_is_not_running() {
1343 let (mut s, p) = store_with_project();
1344 for state in [
1345 TaskState::Proposed,
1346 TaskState::Parked,
1347 TaskState::Ready,
1348 TaskState::NeedsInput,
1349 TaskState::Review,
1350 TaskState::Done,
1351 TaskState::Rejected,
1352 ] {
1353 let id = task_in_state(&mut s, p, state);
1354 assert!(
1355 matches!(
1356 s.record_continuation(id, "claude", None, None),
1357 Err(Error::InvalidTransition { .. })
1358 ),
1359 "continuation should be refused from {state}"
1360 );
1361 assert!(s.sessions_for(id).unwrap().is_empty());
1363 }
1364 }
1365
1366 #[test]
1369 fn terminal_transitions_close_the_open_session_with_the_right_outcome() {
1370 let (mut s, p) = store_with_project();
1371
1372 let accepted = create(&mut s, p, TaskState::Ready);
1375 let sess = s
1376 .record_dispatch(accepted, "claude", Some(1), None)
1377 .unwrap()
1378 .1;
1379 s.apply(accepted, Action::Complete(None)).unwrap();
1380 assert!(
1381 s.session(sess.id).unwrap().ended_at.is_none(),
1382 "review keeps it open"
1383 );
1384 s.apply(accepted, Action::Accept).unwrap();
1385 let closed = s.session(sess.id).unwrap();
1386 assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1387 assert!(closed.ended_at.is_some());
1388
1389 let aborted = create(&mut s, p, TaskState::Ready);
1391 let sess = s
1392 .record_dispatch(aborted, "claude", Some(1), None)
1393 .unwrap()
1394 .1;
1395 s.apply(aborted, Action::Abort).unwrap();
1396 assert_eq!(
1397 s.session(sess.id).unwrap().outcome,
1398 Some(SessionOutcome::Aborted)
1399 );
1400
1401 let abandoned = create(&mut s, p, TaskState::Ready);
1403 let sess = s
1404 .record_dispatch(abandoned, "claude", Some(1), None)
1405 .unwrap()
1406 .1;
1407 s.apply(abandoned, Action::Complete(None)).unwrap();
1408 s.apply(abandoned, Action::Abandon).unwrap();
1409 assert_eq!(
1410 s.session(sess.id).unwrap().outcome,
1411 Some(SessionOutcome::Aborted)
1412 );
1413 }
1414
1415 #[test]
1416 fn a_continuation_ends_the_predecessor_open_session() {
1417 let (mut s, p) = store_with_project();
1418 let id = create(&mut s, p, TaskState::Ready);
1419 let first = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1420
1421 s.apply(id, Action::Ask("A or B?".into())).unwrap();
1423 s.apply(id, Action::Answer("B".into())).unwrap();
1424 assert!(s.session(first.id).unwrap().ended_at.is_none());
1425
1426 let second = s
1429 .record_continuation(id, "claude", Some(2), None)
1430 .unwrap()
1431 .1;
1432 assert!(s.session(first.id).unwrap().ended_at.is_some());
1433 let open: Vec<i64> = s
1434 .sessions_for(id)
1435 .unwrap()
1436 .into_iter()
1437 .filter(|x| x.ended_at.is_none())
1438 .map(|x| x.id)
1439 .collect();
1440 assert_eq!(open, vec![second.id]);
1441 }
1442
1443 #[test]
1444 fn rejecting_a_review_keeps_the_same_session_open_with_its_ref() {
1445 let (mut s, p) = store_with_project();
1449 let id = create(&mut s, p, TaskState::Ready);
1450 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1451 s.set_session_ref(sess.id, "ref-1").unwrap();
1452 s.apply(id, Action::Complete(None)).unwrap();
1453 s.apply(id, Action::RejectWork("redo the tests".into()))
1454 .unwrap();
1455
1456 let live = s.session(sess.id).unwrap();
1457 assert!(live.ended_at.is_none(), "reject leaves the session open");
1458 assert_eq!(live.session_ref.as_deref(), Some("ref-1"));
1459 assert_eq!(s.task(id).unwrap().state, TaskState::Running);
1460 }
1461
1462 mod waiting {
1465 use super::*;
1466
1467 #[test]
1468 fn hand_off_keeps_the_session_open_for_a_later_continuation() {
1469 let (mut s, p) = store_with_project();
1473 let id = create(&mut s, p, TaskState::Ready);
1474 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1475 s.apply(id, Action::Complete(None)).unwrap();
1476
1477 let task = s.apply(id, Action::HandOff).unwrap();
1478 assert_eq!(task.state, TaskState::Waiting);
1479 assert!(
1480 s.session(sess.id).unwrap().ended_at.is_none(),
1481 "waiting keeps the session open"
1482 );
1483 }
1484
1485 #[test]
1486 fn hand_off_is_refused_from_states_other_than_review() {
1487 let (mut s, p) = store_with_project();
1490 for state in [
1491 TaskState::Proposed,
1492 TaskState::Parked,
1493 TaskState::Ready,
1494 TaskState::Running,
1495 TaskState::NeedsInput,
1496 TaskState::Stalled,
1497 ] {
1498 let id = task_in_state(&mut s, p, state);
1499 assert!(
1500 matches!(
1501 s.apply(id, Action::HandOff),
1502 Err(Error::InvalidTransition { .. })
1503 ),
1504 "hand off should be refused from {state}"
1505 );
1506 }
1507 }
1508
1509 #[test]
1510 fn accept_from_waiting_closes_the_session_completed() {
1511 let (mut s, p) = store_with_project();
1514 let id = create(&mut s, p, TaskState::Ready);
1515 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1516 s.apply(id, Action::Complete(None)).unwrap();
1517 s.apply(id, Action::HandOff).unwrap();
1518
1519 let task = s.apply(id, Action::Accept).unwrap();
1520 assert_eq!(task.state, TaskState::Done);
1521 assert!(task.closed_at.is_some());
1522 let closed = s.session(sess.id).unwrap();
1523 assert!(closed.ended_at.is_some());
1524 assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1525 }
1526
1527 #[test]
1528 fn abandon_from_waiting_closes_the_session_aborted() {
1529 let (mut s, p) = store_with_project();
1530 let id = create(&mut s, p, TaskState::Ready);
1531 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1532 s.apply(id, Action::Complete(None)).unwrap();
1533 s.apply(id, Action::HandOff).unwrap();
1534
1535 let task = s.apply(id, Action::Abandon).unwrap();
1536 assert_eq!(task.state, TaskState::Rejected);
1537 assert_eq!(
1538 s.session(sess.id).unwrap().outcome,
1539 Some(SessionOutcome::Aborted)
1540 );
1541 }
1542
1543 #[test]
1544 fn reclaim_pulls_the_work_back_to_review_keeping_the_session() {
1545 let (mut s, p) = store_with_project();
1546 let id = create(&mut s, p, TaskState::Ready);
1547 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1548 s.apply(id, Action::Complete(None)).unwrap();
1549 s.apply(id, Action::HandOff).unwrap();
1550
1551 let task = s.apply(id, Action::Reclaim).unwrap();
1552 assert_eq!(task.state, TaskState::Review);
1553 assert!(s.session(sess.id).unwrap().ended_at.is_none());
1554 }
1555
1556 #[test]
1557 fn reject_from_waiting_continues_the_same_session_with_feedback() {
1558 let (mut s, p) = store_with_project();
1562 let id = create(&mut s, p, TaskState::Ready);
1563 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1564 s.set_session_ref(sess.id, "ref-1").unwrap();
1565 s.apply(id, Action::Complete(None)).unwrap();
1566 s.apply(id, Action::HandOff).unwrap();
1567
1568 let task = s
1569 .apply(id, Action::RejectWork("reviewer wants tests".into()))
1570 .unwrap();
1571 assert_eq!(task.state, TaskState::Running);
1572 assert!(task.body.contains("## Feedback"));
1573 assert!(task.body.contains("- reviewer wants tests"));
1574
1575 let live = s.session(sess.id).unwrap();
1576 assert!(live.ended_at.is_none(), "reject keeps the session open");
1577 assert_eq!(live.session_ref.as_deref(), Some("ref-1"));
1578 }
1579
1580 #[test]
1581 fn reconcile_leaves_a_waiting_session_untouched() {
1582 let (mut s, p) = store_with_project();
1585 let id = create(&mut s, p, TaskState::Ready);
1586 let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1;
1587 s.apply(id, Action::Complete(None)).unwrap();
1588 s.apply(id, Action::HandOff).unwrap();
1589
1590 assert!(
1591 s.reconcile_session(sess.id, false, false)
1592 .unwrap()
1593 .is_none()
1594 );
1595 assert!(s.session(sess.id).unwrap().ended_at.is_none());
1596 assert_eq!(s.task(id).unwrap().state, TaskState::Waiting);
1597 }
1598 }
1599
1600 #[test]
1601 fn a_second_open_session_violates_the_unique_index() {
1602 let (mut s, p) = store_with_project();
1605 let id = create(&mut s, p, TaskState::Ready);
1606 s.record_dispatch(id, "claude", Some(1), None).unwrap();
1607 let second = s.conn.execute(
1608 "INSERT INTO sessions (task_id, agent, started_at) VALUES (?1, 'x', datetime('now'))",
1609 [id],
1610 );
1611 assert!(second.is_err(), "a second open session must be rejected");
1612 }
1613
1614 #[test]
1615 fn terminal_states_are_final() {
1616 let (mut s, p) = store_with_project();
1617 for state in [TaskState::Done, TaskState::Rejected] {
1618 let id = task_in_state(&mut s, p, state);
1619 for action in all_actions() {
1620 assert!(s.apply(id, action).is_err(), "{state} must be terminal");
1621 }
1622 }
1623 }
1624
1625 mod reconcile {
1628 use super::*;
1629 use crate::model::SessionOutcome;
1630
1631 fn dispatch(s: &mut Store, p: i64) -> (i64, i64) {
1632 let task_id = create(s, p, TaskState::Ready);
1633 let (_, session) = s
1634 .record_dispatch(task_id, "claude", Some(4242), Some("/var/log/s.log"))
1635 .unwrap();
1636 (task_id, session.id)
1637 }
1638
1639 #[test]
1640 fn live_pid_is_left_untouched() {
1641 let (mut s, p) = store_with_project();
1642 let (task_id, session_id) = dispatch(&mut s, p);
1643
1644 let result = s.reconcile_session(session_id, true, false).unwrap();
1645 assert!(result.is_none());
1646 assert_eq!(s.task(task_id).unwrap().state, TaskState::Running);
1647 assert!(s.session(session_id).unwrap().ended_at.is_none());
1648 }
1649
1650 #[test]
1651 fn dead_pid_on_a_running_task_stalls_it() {
1652 let (mut s, p) = store_with_project();
1653 let (task_id, session_id) = dispatch(&mut s, p);
1654
1655 let (session, task) = s
1656 .reconcile_session(session_id, false, false)
1657 .unwrap()
1658 .unwrap();
1659 assert_eq!(session.outcome, Some(SessionOutcome::Failed));
1660 assert!(session.ended_at.is_some());
1661 assert_eq!(task.state, TaskState::Stalled);
1664
1665 let events = s.events_for(task_id).unwrap();
1666 let transition = events.last().unwrap();
1667 assert_eq!(transition.kind, "transition");
1668 assert_eq!(
1669 transition.detail.as_deref(),
1670 Some("running -> stalled"),
1671 "{events:?}"
1672 );
1673 let reconcile = &events[events.len() - 2];
1674 assert_eq!(reconcile.kind, "reconcile");
1675 let detail = reconcile.detail.as_deref().unwrap();
1676 assert!(detail.contains("without reporting"), "{detail}");
1677 assert!(detail.contains("failed"), "{detail}");
1678 }
1679
1680 #[test]
1681 fn dead_pid_reports_capped_when_the_caller_says_so() {
1682 let (mut s, p) = store_with_project();
1683 let (_, session_id) = dispatch(&mut s, p);
1684
1685 let (session, task) = s
1686 .reconcile_session(session_id, false, true)
1687 .unwrap()
1688 .unwrap();
1689 assert_eq!(session.outcome, Some(SessionOutcome::Capped));
1690 assert_eq!(task.state, TaskState::Stalled);
1693 }
1694
1695 #[test]
1696 fn a_stalled_task_can_be_redispatched() {
1697 let (mut s, p) = store_with_project();
1700 let (task_id, session_id) = dispatch(&mut s, p);
1701 s.reconcile_session(session_id, false, false).unwrap();
1702 assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
1703
1704 let (task, session) = s
1705 .record_dispatch(task_id, "codex", Some(4343), Some("/var/log/s2.log"))
1706 .unwrap();
1707 assert_eq!(task.state, TaskState::Running);
1708 assert_eq!(session.agent, "codex");
1709 assert!(session.ended_at.is_none());
1710 }
1711
1712 #[test]
1713 fn a_stalled_task_completes_to_review_on_the_dead_sessions_behalf() {
1714 let (mut s, p) = store_with_project();
1719 let (task_id, session_id) = dispatch(&mut s, p);
1720 s.reconcile_session(session_id, false, false).unwrap();
1721 assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
1722
1723 let task = s
1724 .apply(task_id, Action::Complete(Some("landed on feat/x".into())))
1725 .unwrap();
1726 assert_eq!(task.state, TaskState::Review);
1727
1728 let session = s.session(session_id).unwrap();
1729 assert!(session.ended_at.is_some());
1730 assert_eq!(session.outcome, Some(SessionOutcome::Failed));
1731 assert!(
1732 s.sessions_for(task_id)
1733 .unwrap()
1734 .iter()
1735 .all(|x| x.ended_at.is_some()),
1736 "completing a stall must not open a session"
1737 );
1738
1739 let events = s.events_for(task_id).unwrap();
1740 assert!(
1741 events
1742 .iter()
1743 .any(|e| e.kind == "transition"
1744 && e.detail.as_deref() == Some("stalled -> review")),
1745 "{events:?}"
1746 );
1747 assert!(
1748 events
1749 .iter()
1750 .any(|e| e.kind == "summary"
1751 && e.detail.as_deref() == Some("landed on feat/x")),
1752 "{events:?}"
1753 );
1754 }
1755
1756 #[test]
1757 fn a_stalled_task_with_an_open_blocker_is_parked() {
1758 let (mut s, p) = store_with_project();
1762 let (task_id, session_id) = dispatch(&mut s, p);
1763 let blocker = create(&mut s, p, TaskState::Ready);
1764 s.add_dep(task_id, blocker, crate::model::DepKind::Blocks)
1765 .unwrap();
1766
1767 let (_, task) = s
1768 .reconcile_session(session_id, false, false)
1769 .unwrap()
1770 .unwrap();
1771 assert_eq!(task.state, TaskState::Parked);
1772 }
1773
1774 #[test]
1775 fn adding_an_open_blocker_demotes_a_stalled_task() {
1776 let (mut s, p) = store_with_project();
1777 let (task_id, session_id) = dispatch(&mut s, p);
1778 s.reconcile_session(session_id, false, false).unwrap();
1779 assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
1780
1781 let blocker = create(&mut s, p, TaskState::Ready);
1782 s.add_dep(task_id, blocker, crate::model::DepKind::Blocks)
1783 .unwrap();
1784 assert_eq!(s.task(task_id).unwrap().state, TaskState::Parked);
1785
1786 s.apply(blocker, Action::Start).unwrap();
1789 s.apply(blocker, Action::Complete(None)).unwrap();
1790 s.apply(blocker, Action::Accept).unwrap();
1791 assert_eq!(s.task(task_id).unwrap().state, TaskState::Ready);
1792 }
1793
1794 #[test]
1795 fn a_needs_input_tasks_session_stays_open() {
1796 let (mut s, p) = store_with_project();
1800 let (task_id, session_id) = dispatch(&mut s, p);
1801 s.apply(task_id, Action::Ask("A or B?".into())).unwrap();
1802
1803 assert!(
1804 s.reconcile_session(session_id, false, false)
1805 .unwrap()
1806 .is_none()
1807 );
1808 assert!(s.session(session_id).unwrap().ended_at.is_none());
1809 assert_eq!(s.task(task_id).unwrap().state, TaskState::NeedsInput);
1810 }
1811
1812 #[test]
1813 fn a_review_tasks_session_stays_open() {
1814 let (mut s, p) = store_with_project();
1817 let (task_id, session_id) = dispatch(&mut s, p);
1818 s.apply(task_id, Action::Complete(None)).unwrap();
1819
1820 assert!(
1821 s.reconcile_session(session_id, false, false)
1822 .unwrap()
1823 .is_none()
1824 );
1825 assert!(s.session(session_id).unwrap().ended_at.is_none());
1826 assert_eq!(s.task(task_id).unwrap().state, TaskState::Review);
1827 }
1828
1829 #[test]
1830 fn aborting_closes_the_session_so_reconcile_is_a_noop() {
1831 let (mut s, p) = store_with_project();
1834 let (task_id, session_id) = dispatch(&mut s, p);
1835 s.apply(task_id, Action::Abort).unwrap();
1836
1837 let session = s.session(session_id).unwrap();
1838 assert_eq!(session.outcome, Some(SessionOutcome::Aborted));
1839 assert!(session.ended_at.is_some());
1840 assert!(
1841 s.reconcile_session(session_id, false, false)
1842 .unwrap()
1843 .is_none()
1844 );
1845 assert_eq!(s.task(task_id).unwrap().state, TaskState::Ready);
1848 }
1849
1850 #[test]
1851 fn a_stale_open_session_on_a_closed_task_is_finalised() {
1852 let (mut s, p) = store_with_project();
1856 let (task_id, session_id) = dispatch(&mut s, p);
1857 s.conn
1858 .execute(
1859 "UPDATE tasks SET state = 'done', closed_at = datetime('now') WHERE id = ?1",
1860 [task_id],
1861 )
1862 .unwrap();
1863
1864 let (session, task) = s
1865 .reconcile_session(session_id, false, false)
1866 .unwrap()
1867 .unwrap();
1868 assert!(session.ended_at.is_some());
1869 assert_eq!(session.outcome, Some(SessionOutcome::Completed));
1870 assert_eq!(task.state, TaskState::Done);
1871 }
1872
1873 #[test]
1874 fn an_already_ended_session_is_not_reprocessed() {
1875 let (mut s, p) = store_with_project();
1876 let (_, session_id) = dispatch(&mut s, p);
1877 s.reconcile_session(session_id, false, false).unwrap();
1878 let first_ended_at = s.session(session_id).unwrap().ended_at;
1879
1880 let result = s.reconcile_session(session_id, false, false).unwrap();
1881 assert!(result.is_none());
1882 assert_eq!(s.session(session_id).unwrap().ended_at, first_ended_at);
1883 }
1884
1885 #[test]
1886 fn redispatch_supersedes_the_prior_session_keeping_one_open() {
1887 let (mut s, p) = store_with_project();
1891 let (task_id, older_session) = dispatch(&mut s, p);
1892 s.apply(task_id, Action::Abort).unwrap();
1893 assert!(s.session(older_session).unwrap().ended_at.is_some());
1894
1895 let newer_session = s
1896 .record_dispatch(task_id, "claude", Some(4343), Some("/var/log/s2.log"))
1897 .unwrap()
1898 .1
1899 .id;
1900
1901 let open: Vec<i64> = s
1903 .sessions_for(task_id)
1904 .unwrap()
1905 .into_iter()
1906 .filter(|x| x.ended_at.is_none())
1907 .map(|x| x.id)
1908 .collect();
1909 assert_eq!(open, vec![newer_session]);
1910
1911 assert!(
1913 s.reconcile_session(older_session, false, false)
1914 .unwrap()
1915 .is_none()
1916 );
1917 assert_eq!(s.task(task_id).unwrap().state, TaskState::Running);
1918 assert!(s.session(newer_session).unwrap().ended_at.is_none());
1919 }
1920
1921 #[test]
1922 fn unknown_session_is_an_error() {
1923 let (mut s, _p) = store_with_project();
1924 assert!(matches!(
1925 s.reconcile_session(9999, false, false),
1926 Err(Error::SessionNotFound(9999))
1927 ));
1928 }
1929 }
1930
1931 #[test]
1932 fn add_dep_rejects_self_blocks() {
1933 let (mut s, p) = store_with_project();
1934 let task = create(&mut s, p, TaskState::Ready);
1935 let err = s.add_dep(task, task, DepKind::Blocks).unwrap_err();
1936 assert!(
1937 matches!(&err, Error::DependencyCycle(path) if path == &format!("{task} -> {task}")),
1938 "expected a self-cycle error, got {err}"
1939 );
1940 }
1941
1942 #[test]
1943 fn add_dep_rejects_direct_cycle() {
1944 let (mut s, p) = store_with_project();
1945 let a = create(&mut s, p, TaskState::Ready);
1946 let b = create(&mut s, p, TaskState::Ready);
1947 s.add_dep(b, a, DepKind::Blocks).unwrap();
1948
1949 let err = s.add_dep(a, b, DepKind::Blocks).unwrap_err();
1950 assert!(
1951 matches!(&err, Error::DependencyCycle(path) if path == &format!("{a} -> {b} -> {a}")),
1952 "expected a direct cycle error, got {err}"
1953 );
1954 assert!(s.deps_of(a).unwrap().is_empty());
1956 }
1957
1958 #[test]
1959 fn add_dep_rejects_transitive_cycle() {
1960 let (mut s, p) = store_with_project();
1961 let a = create(&mut s, p, TaskState::Ready);
1962 let b = create(&mut s, p, TaskState::Ready);
1963 let c = create(&mut s, p, TaskState::Ready);
1964 s.add_dep(b, c, DepKind::Blocks).unwrap();
1965 s.add_dep(c, a, DepKind::Blocks).unwrap();
1966
1967 let err = s.add_dep(a, b, DepKind::Blocks).unwrap_err();
1968 assert!(
1969 matches!(&err, Error::DependencyCycle(path)
1970 if path == &format!("{a} -> {b} -> {c} -> {a}")),
1971 "expected a transitive cycle error, got {err}"
1972 );
1973 }
1974
1975 #[test]
1976 fn add_dep_allows_a_diamond() {
1977 let (mut s, p) = store_with_project();
1979 let a = create(&mut s, p, TaskState::Ready);
1980 let b = create(&mut s, p, TaskState::Ready);
1981 let c = create(&mut s, p, TaskState::Ready);
1982 let d = create(&mut s, p, TaskState::Ready);
1983 s.add_dep(b, d, DepKind::Blocks).unwrap();
1984 s.add_dep(c, d, DepKind::Blocks).unwrap();
1985 s.add_dep(a, b, DepKind::Blocks).unwrap();
1986 s.add_dep(a, c, DepKind::Blocks).unwrap();
1987
1988 let deps = s.deps_of(a).unwrap();
1989 assert_eq!(
1990 deps.iter().map(|d| d.depends_on).collect::<Vec<_>>(),
1991 vec![b, c]
1992 );
1993 }
1994
1995 #[test]
1996 fn set_blocks_deps_rejects_self_and_transitive_cycles() {
1997 let (mut s, p) = store_with_project();
1998 let a = create(&mut s, p, TaskState::Ready);
1999 let b = create(&mut s, p, TaskState::Ready);
2000 let c = create(&mut s, p, TaskState::Ready);
2001
2002 let err = s.set_blocks_deps(a, &[a]).unwrap_err();
2003 assert!(matches!(&err, Error::DependencyCycle(path) if path == &format!("{a} -> {a}")));
2004
2005 s.set_blocks_deps(b, &[c]).unwrap();
2007 s.set_blocks_deps(c, &[a]).unwrap();
2008 let err = s.set_blocks_deps(a, &[b]).unwrap_err();
2009 assert!(matches!(&err, Error::DependencyCycle(path)
2010 if path == &format!("{a} -> {b} -> {c} -> {a}")));
2011 assert!(s.deps_of(a).unwrap().is_empty());
2013 }
2014
2015 #[test]
2016 fn set_blocks_deps_allows_a_diamond() {
2017 let (mut s, p) = store_with_project();
2018 let a = create(&mut s, p, TaskState::Ready);
2019 let b = create(&mut s, p, TaskState::Ready);
2020 let c = create(&mut s, p, TaskState::Ready);
2021 let d = create(&mut s, p, TaskState::Ready);
2022 s.set_blocks_deps(b, &[d]).unwrap();
2023 s.set_blocks_deps(c, &[d]).unwrap();
2024
2025 let t = s.set_blocks_deps(a, &[b, c]).unwrap();
2026 assert_eq!(t.state, TaskState::Parked);
2027 }
2028}