Skip to main content

voro_core/
transition.rs

1//! The task state machine (DESIGN.md §6). `Store::apply` is the only path
2//! that changes `tasks.state`; it validates the transition, restamps
3//! `state_since`, maintains the `question`/`closed_at` invariants, appends to
4//! the event log, and cascades readiness of dependant tasks — all in one
5//! transaction.
6
7use 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/// Where a `proposed` task goes at triage.
19#[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    /// proposed → parked | ready | rejected
29    Triage(Triage),
30    /// ready | stalled → running (dispatch or redispatch, or the human
31    /// starting by hand)
32    Start,
33    /// running → needs-input; the string is the question
34    Ask(String),
35    /// needs-input → running; the answer is appended to the body and logged
36    Answer(String),
37    /// running | stalled → review; the optional string is the completion
38    /// summary, logged as a `summary` event. From `stalled` it reports a dead
39    /// session's finished work on its behalf (DESIGN.md §8).
40    Complete(Option<String>),
41    /// review → waiting; hand the work off to an external party (a PR awaiting
42    /// someone else's review or merge), asking nothing of the operator (§6).
43    HandOff,
44    /// waiting → review; pull the work back when it is the operator's move
45    /// again — the manual inverse of `HandOff` (§6).
46    Reclaim,
47    /// review | waiting → done
48    Accept,
49    /// review | waiting → running; the string is the feedback, appended to the
50    /// body
51    RejectWork(String),
52    /// running → ready
53    Abort,
54    /// ready | stalled → parked (deliberate parking)
55    Park,
56    /// parked → ready (manual unpark)
57    Unpark,
58    /// parked | ready | needs-input | review | waiting | stalled → rejected
59    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    /// Legal target of `action` from `state`, if any. Exposed so interfaces can
84    /// offer exactly the legal actions without duplicating the machine. Order
85    /// matters: interfaces render the first entry selected, so the most common
86    /// action leads (for triage, `ready`). `human` shortens the running path
87    /// (DESIGN.md §6): no `ask`, and completion leads since it goes straight to
88    /// `done`.
89    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    /// Dispatch's atomic write (DESIGN.md §8): move the task `ready → running`
136    /// (or `stalled → running`) and open its session in one transaction, so a
137    /// running task always has a session and a session always has a running
138    /// task. Spawning the process is the caller's job, before this commits.
139    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    /// Record a continuation session (DESIGN.md §6/§8): the answer to a
155    /// `needs-input` question is fed back not through a live pipe but by
156    /// dispatching a fresh session over the task body, now carrying the appended
157    /// `## Answers` section. Unlike [`record_dispatch`](Store::record_dispatch),
158    /// this asserts the task is *already* `running`, so it never weakens the
159    /// ready-only rule — it only adds a session to a task the transition API
160    /// already moved.
161    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    /// Reconcile an open session against its task's state (DESIGN.md §8). The
183    /// session's life follows the task, not the process listing, so the terminal
184    /// transitions close healthy sessions; reconciliation only catches a crash
185    /// or cap mid-`running` and finalises sessions stranded on already-closed
186    /// tasks. `pid_alive`/`likely_capped` are supplied by the caller (voro-core
187    /// does no process or log I/O) and matter only for a `running` task:
188    ///
189    /// - session already ended: no-op (`Ok(None)`), so a repeated sweep can't
190    ///   double-finalise it.
191    /// - `running`, `pid_alive`: left untouched.
192    /// - `running`, process gone: outcome recorded (`capped`/`failed`) and the
193    ///   task goes `running → stalled` (DESIGN.md §6/§8) — an attention row never
194    ///   handed out by `voro next`; a late `done` lands it in `review` on the
195    ///   dead session's behalf. A stalled task with an open blocker demotes to
196    ///   `parked`.
197    /// - `needs-input`/`review`/`waiting`: the session stays open on purpose
198    ///   (reused by the continuation), so this leaves it alone (`Ok(None)`)
199    ///   regardless of liveness.
200    /// - task already closed or off the active path: the session is stale, so it
201    ///   is finalised now (`completed` for `done`, else `aborted`) with no event.
202    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            // The session is meant to stay open here; nothing to reconcile.
227            // `waiting` keeps it open like `review` so a reject-with-feedback
228            // can continue the same agent session (DESIGN.md §8).
229            TaskState::NeedsInput | TaskState::Review | TaskState::Waiting => return Ok(None),
230            // Stale: a session still open on a task that has left the active
231            // path. Close it with the outcome that fits where the task landed.
232            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    /// Replace the `blocks` dependencies of a task with the given set, then
270    /// reconcile its readiness. This is the dep-editing entry point for
271    /// interfaces; `add_dep`/`remove_dep` reconcile too.
272    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    /// The reverse authoring direction of
297    /// [`set_blocks_deps`](Store::set_blocks_deps): make `blocker_id` a blocker
298    /// of each task in `dependents`. Additive and idempotent (replacing the set
299    /// here would detach edges other tasks authored). Each dependent's readiness
300    /// is reconciled in the same write; the returned pairs carry its prior state
301    /// so callers can surface a demotion.
302    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
330/// The state machine proper: validate `action` against the task's current
331/// state, restamp `state_since`, maintain the `question`/`closed_at`
332/// invariants, append to the event log, and cascade dependant readiness —
333/// against an already-open transaction so callers can bundle further writes
334/// (a session insert, for dispatch) into the same atomic unit.
335fn 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        // A human task cannot be blocked on a decision — the executor *is* the
345        // human (DESIGN.md §6).
346        (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        // Completing a human task skips `review`: the human is both executor
357        // and acceptor, so there is no one left to accept the work (§6).
358        (Running | Stalled, Action::Complete(_)) if task.human => Done,
359        // From `stalled`, completion reports a dead session's finished work on
360        // its behalf — the misfire case (§8). The session is already closed, so
361        // only the state moves.
362        (Running | Stalled, Action::Complete(_)) => Review,
363        // Hand a finished review off to an external party and pull it back —
364        // `waiting` asks nothing of the operator while it is someone else's
365        // move (DESIGN.md §6). Only from `review` for now.
366        (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    // The session's life follows the task (DESIGN.md §8): terminal transitions
428    // close the task's open session in the same transaction, while
429    // Ask/Complete/Answer/RejectWork deliberately leave it open for reuse across
430    // needs-input/review.
431    match &action {
432        Action::Accept => {
433            close_open_session(tx, task_id, SessionOutcome::Completed)?;
434        }
435        // A human completion is itself terminal (running → done), so it owns the
436        // teardown; the close is belt-and-braces, since no agent session should
437        // exist on a human task.
438        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        // `ready` must mean genuinely actionable: a transition landing here with
451        // a blocker still open (triage, abort, unpark) reconciles back to
452        // `parked`.
453        reconcile_readiness(tx, task_id)?;
454    }
455
456    Ok(to)
457}
458
459/// Refuse to open an agent session on a human-only task (DESIGN.md §6/§8):
460/// dispatch, redispatch, and continuation all route through here, before any
461/// state change or session insert, so the refusal writes nothing.
462fn 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
473/// Reject `from` acquiring a `blocks` dependency on `to` if doing so would
474/// close a cycle in the `blocks` graph (a task blocking itself, directly or
475/// transitively). Called by every write path that adds a `blocks` edge.
476pub(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
488/// Would adding the edge `from` --blocks--> `to` close a cycle? Equivalent to
489/// asking whether `to` can already reach `from` by following existing
490/// `blocks` edges (`task_id -> depends_on`) — if so, the new edge would let
491/// `from` walk out to `to` and back to itself. Self-deps are the degenerate
492/// case where `from == to`, a zero-hop cycle. Returns the cycle in task-id
493/// order starting and ending at `from`, e.g. `[3, 7, 3]`.
494fn 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    // BFS outward from `to`, following `blocks` edges, recording each node's
500    // predecessor so a path back to `to` can be rebuilt if `from` turns up.
501    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(); // now to -> ... -> from
514            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
533/// Append `text` under a `## {heading}` section at the end of the body,
534/// creating the section on first use.
535fn 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
556/// After `closed_id` reaches a terminal state, re-check every task that
557/// `blocks`-depends on it (DESIGN.md §5: promotion happens the moment the
558/// last blocker closes).
559fn 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
571/// Enforce readiness against `blocks` dependencies:
572/// - `parked` with at least one blocker, all closed → promote to `ready`.
573///   A parked task with *no* blockers is deliberately parked and stays put.
574/// - `ready` or `stalled` with an open blocker → demote to `parked`. A
575///   stalled task re-promotes to `ready`, not `stalled`, when the blocker
576///   closes — by then the stall context is stale (DESIGN.md §6).
577pub(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    /// Walk a fresh task into `state` through the transition API itself.
640    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    /// The full §6 matrix: expected target state for every (state, action)
704    /// pair, `None` meaning the transition is illegal.
705    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                        // Triage variants share a discriminant; all are legal
764                        // exactly when the state is proposed.
765                        (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    // --- human-only tasks (DESIGN.md §3/§6): the shortened path ---
778
779    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            // the summary still rides the completion, as on the agent path
816            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            // running → done is terminal, so it must cascade readiness exactly
870            // as an accept does.
871            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            // No agent session should ever exist on a human task, but the
884            // terminal completion still tears one down (sessions follow the
885            // task, §8) if a legacy or hand-made row is lying around.
886            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            // every other state offers the same menu regardless of the flag
902            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        /// The matrix over the states a human task can actually reach —
914        /// `needs-input` and `review` are unreachable by construction (§6).
915        #[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                        // the two divergences from the agent path
928                        (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        // failed applies must not have changed anything
999        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        // a rejected blocker no longer blocks either
1116        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        // ...and closing that blocker brings it straight back.
1150        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        // The human triages it "ready", but an open blocker overrides that: it
1164        // lands in parked and never reaches the scheduler.
1165        let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1166        assert_eq!(triaged.state, TaskState::Parked);
1167
1168        // closing the blocker auto-promotes it exactly like any parked task
1169        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        // a closed blocker does not gate readiness
1182        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        // adding a blocker to a running task leaves it running...
1192        s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1193        assert_eq!(s.task(task).unwrap().state, TaskState::Running);
1194        // ...but aborting must not expose it as ready while still blocked
1195        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        // a manual unpark cannot override an open blocker
1207        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        // dropping the open blocker (one closed dep remains) promotes
1222        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        // closing the blocker promotes both dependents
1250        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        // b waits on a, c waits on b; making c block a would close the loop
1278        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        // self-block is the zero-hop cycle
1284        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        // the failed transaction must leave neither state change nor session
1315        assert_eq!(s.task(id).unwrap().state, TaskState::Proposed);
1316        assert!(s.sessions_for(id).unwrap().is_empty());
1317    }
1318
1319    // --- record_continuation (DESIGN.md §6/§8: answer → running → continue) ---
1320
1321    #[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            // refusal must not create a session
1362            assert!(s.sessions_for(id).unwrap().is_empty());
1363        }
1364    }
1365
1366    // --- session lifecycle: one open session, closed by terminal transitions ---
1367
1368    #[test]
1369    fn terminal_transitions_close_the_open_session_with_the_right_outcome() {
1370        let (mut s, p) = store_with_project();
1371
1372        // Accept: the session is kept open through review, then closed
1373        // `completed` when the review is accepted.
1374        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        // Abort: running -> ready closes the session `aborted`.
1390        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        // Abandon (from review) closes the session `aborted` too.
1402        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        // ask/answer leave the session open across needs-input -> running...
1422        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        // ...then a continuation supersedes it: the predecessor is closed and
1427        // the new session is the only one open (the invariant).
1428        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        // Rejecting with feedback returns the task to running with its session
1446        // still open, so a continuation reuses the same agent session — the
1447        // ref survives until the task actually closes (DESIGN.md §8).
1448        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    // --- waiting (DESIGN.md §6/§8): work handed off to an external party ---
1463
1464    mod waiting {
1465        use super::*;
1466
1467        #[test]
1468        fn hand_off_keeps_the_session_open_for_a_later_continuation() {
1469            // review → waiting must keep the session open exactly as review
1470            // does, so a reject-with-feedback can continue the same agent
1471            // session once the work becomes the operator's move again.
1472            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            // Only review → waiting for now (DESIGN.md §6): a running task is
1488            // not yet handed off, and every other state is nonsensical.
1489            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            // The PR merged: accept closes the session `completed`, exactly as
1512            // it does straight from review (DESIGN.md §8).
1513            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            // The acceptance path: review → wait → reject-with-feedback returns
1559            // the task to running with its original session still open, and the
1560            // feedback recorded, so a continuation reuses that agent session.
1561            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            // Like review, a waiting task's session stays open regardless of
1583            // process liveness — nothing to reconcile (DESIGN.md §8).
1584            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        // The schema backstop: even a raw insert bypassing insert_session's
1603        // supersede cannot leave two open rows on one task.
1604        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    // --- reconcile_session (DESIGN.md §8, the observation half of dispatch) ---
1626
1627    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            // the dispatch died under the task: running -> stalled (DESIGN.md
1662            // §6/§8), the attention state that puts redispatch in the queue.
1663            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            // a cap stalls the task the same way a failure does; redispatch
1691            // happens from `stalled` once quota resets (DESIGN.md §6/§8).
1692            assert_eq!(task.state, TaskState::Stalled);
1693        }
1694
1695        #[test]
1696        fn a_stalled_task_can_be_redispatched() {
1697            // record_dispatch's precondition accepts stalled -> running:
1698            // redispatch is the whole point of the state (DESIGN.md §8).
1699            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            // The misfire case (DESIGN.md §8): the session finished but its
1715            // `done` never landed, so reconcile stalled the task. Completion
1716            // from `stalled` reaches review directly — no session is reopened
1717            // and the dead session keeps its recorded outcome.
1718            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            // Readiness reconciliation treats stalled like ready (DESIGN.md
1759            // §6): a blocker opened mid-run means the stall lands in parked,
1760            // never surfacing unactionable work in the queue.
1761            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            // when the blocker closes it re-promotes to ready, not stalled —
1787            // the stall context is stale by then (DESIGN.md §6).
1788            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            // The asking session is reused when the answer continues the work,
1797            // so it stays open across needs-input; reconcile leaves it alone
1798            // even with a dead process (DESIGN.md §8).
1799            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            // Review keeps the session open on purpose, so a reject-with-feedback
1815            // can continue the same agent session; reconcile must not close it.
1816            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            // Abort closes the session itself, in the same transaction, so
1832            // reconcile finds nothing left to do.
1833            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            // a manual abort lands in plain ready, never stalled — the human
1846            // chose to stop the work; nothing about the dispatch died.
1847            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            // A `done` task still carrying an open session (a stranded row):
1853            // reconcile finalises it on the next pass. Forcing the state
1854            // directly reproduces the row `Accept` would otherwise have closed.
1855            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            // dispatch, abort, redispatch: abort closes the first session, so
1888            // the redispatch opens the only remaining open row. The
1889            // one-open-session invariant holds.
1890            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            // exactly one open session — the newer one
1902            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            // reconciling the old, already-closed session does nothing
1912            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        // the rejected write must not have landed
1955        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        // a depends on b and c; b and c both depend on d. Not a cycle.
1978        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        // b -> c -> a, then a -> b would close the cycle
2006        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        // the rejected write must leave the task's existing blocks deps alone
2012        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}