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, HashSet, VecDeque};
8
9use rusqlite::{Connection, params};
10
11use crate::error::{Error, Result};
12use crate::model::{LivenessSource, RefineOutcome, Session, SessionOutcome, Task, TaskState};
13use crate::store::{
14    Store, close_open_session, get_open_session, get_session, get_task, insert_session, log_event,
15    set_session_outcome,
16};
17
18/// 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    /// proposed | ready → refining; the string is the operator's refine note,
31    /// which rides the transition as a `refined` event (empty for the
32    /// interactive flavour, which is a conversation rather than a brief).
33    Refine(String),
34    /// refining → proposed; the round is over, however it ended.
35    ConcludeRefine(RefineOutcome),
36    /// ready | stalled → running (dispatch or redispatch, or the human
37    /// starting by hand)
38    Start,
39    /// running → needs-input; the string is the question
40    Ask(String),
41    /// needs-input → running; the question was answered in the agent's own
42    /// session, so this only moves the state — no answer text is recorded, the
43    /// exchange lives in the session transcript (DESIGN.md §6/§8).
44    Resume,
45    /// running | stalled → review; the optional string is the completion
46    /// summary, logged as a `summary` event. From `stalled` it reports a dead
47    /// session's finished work on its behalf (DESIGN.md §8).
48    Complete(Option<String>),
49    /// review → waiting; hand the work off to an external party (a PR awaiting
50    /// someone else's review or merge), asking nothing of the operator (§6).
51    HandOff,
52    /// waiting → review; pull the work back when it is the operator's move
53    /// again — the manual inverse of `HandOff` (§6).
54    Reclaim,
55    /// review | waiting → done
56    Accept,
57    /// review | waiting → running; the string is the feedback, appended to the
58    /// body
59    RejectWork(String),
60    /// running → ready
61    Abort,
62    /// ready | stalled → parked (deliberate parking)
63    Park,
64    /// parked → ready (manual unpark)
65    Unpark,
66    /// parked | ready | needs-input | review | waiting | stalled → rejected
67    Abandon,
68}
69
70impl Action {
71    fn name(&self) -> &'static str {
72        match self {
73            Action::Triage(_) => "triage",
74            Action::Refine(_) => "refine",
75            Action::ConcludeRefine(_) => "conclude the refine of",
76            Action::Start => "start",
77            Action::Ask(_) => "ask",
78            Action::Resume => "resume",
79            Action::Complete(_) => "complete",
80            Action::HandOff => "hand off",
81            Action::Reclaim => "reclaim",
82            Action::Accept => "accept",
83            Action::RejectWork(_) => "reject work on",
84            Action::Abort => "abort",
85            Action::Park => "park",
86            Action::Unpark => "unpark",
87            Action::Abandon => "abandon",
88        }
89    }
90
91    /// Whether this transition ends the agent's session for good — the
92    /// operator's closing verdicts on work that is over or being called off
93    /// (DESIGN.md §8). Those are the ones after which the agent may also be
94    /// asked to retire its own registry entry for the session, through its
95    /// optional `stop` verb, so its listing converges on work in flight.
96    ///
97    /// Deliberately narrower than "closes the session row". A refine round's
98    /// conclusion closes a row too, and its commonest trigger is the rewriting
99    /// agent's own `voro set --body-file` — a call made from inside the session,
100    /// mid-turn, so stopping there would kill the agent that just reported. The
101    /// three verdicts here are all issued from outside the session, about work
102    /// the operator has finished with.
103    pub fn stops_session(&self) -> bool {
104        matches!(self, Action::Accept | Action::Abort | Action::Abandon)
105    }
106}
107
108impl Store {
109    /// Legal target of `action` from `state`, if any. Exposed so interfaces can
110    /// offer exactly the legal actions without duplicating the machine. Order
111    /// matters: interfaces render the first entry selected, so the most common
112    /// action leads (for triage, `ready`). `human` shortens the running path
113    /// (DESIGN.md §6): no `ask`, and completion leads since it goes straight to
114    /// `done`.
115    pub fn legal_actions(state: TaskState, human: bool) -> Vec<Action> {
116        use TaskState::*;
117        match state {
118            Proposed => vec![
119                Action::Triage(Triage::Ready),
120                Action::Triage(Triage::Parked),
121                Action::Triage(Triage::Reject),
122            ],
123            // A refine in flight offers only its escape hatch. Triage verdicts
124            // are illegal by construction, which is what closes the race
125            // between the operator and the rewriting agent (DESIGN.md §6).
126            Refining => vec![Action::ConcludeRefine(RefineOutcome::Cancelled)],
127            Parked => vec![Action::Unpark, Action::Abandon],
128            Ready => vec![Action::Start, Action::Park, Action::Abandon],
129            Running if human => vec![Action::Complete(None), Action::Abort],
130            Running => vec![
131                Action::Ask(String::new()),
132                Action::Complete(None),
133                Action::Abort,
134            ],
135            NeedsInput => vec![Action::Resume, Action::Abandon],
136            Review => vec![
137                Action::Accept,
138                Action::RejectWork(String::new()),
139                Action::HandOff,
140                Action::Abandon,
141            ],
142            Waiting => vec![
143                Action::Accept,
144                Action::RejectWork(String::new()),
145                Action::Reclaim,
146                Action::Abandon,
147            ],
148            Stalled => vec![
149                Action::Start,
150                Action::Complete(None),
151                Action::Park,
152                Action::Abandon,
153            ],
154            Done | Rejected => vec![],
155        }
156    }
157
158    pub fn apply(&mut self, task_id: i64, action: Action) -> Result<Task> {
159        self.apply_closing(task_id, action).map(|(task, _)| task)
160    }
161
162    /// [`apply`](Store::apply), plus the session this transition retired, for a
163    /// caller that owns process I/O (DESIGN.md §8). A session's registry entry
164    /// follows its row: on the operator's closing verdicts
165    /// ([`Action::stops_session`]) the shell fires the agent's `stop` verb at
166    /// what comes back here, so the agent's own listing converges on work still
167    /// in flight.
168    ///
169    /// `None` covers every case there is nothing to stop: a transition that
170    /// leaves the session open on purpose, one that closes it as the agent's own
171    /// report rather than a verdict on it, and a task that had no open session
172    /// at all. The session is read back after the commit, so what comes back is
173    /// the closed row rather than the live one it was.
174    pub fn apply_closing(
175        &mut self,
176        task_id: i64,
177        action: Action,
178    ) -> Result<(Task, Option<Session>)> {
179        let tx = self.conn.transaction()?;
180        // Read before the transition, since the close is what makes it
181        // unfindable: after `apply_action` the row is no longer the task's open
182        // session.
183        let closing = if action.stops_session() {
184            get_open_session(&tx, task_id)?.map(|s| s.id)
185        } else {
186            None
187        };
188        apply_action(&tx, task_id, action)?;
189        tx.commit()?;
190        Ok((
191            self.task(task_id)?,
192            closing.map(|id| self.session(id)).transpose()?,
193        ))
194    }
195
196    /// Dispatch's atomic write (DESIGN.md §8): move the task `ready → running`
197    /// (or `stalled → running`) and open its session in one transaction, so a
198    /// running task always has a session and a session always has a running
199    /// task. Spawning the process is the caller's job, before this commits, and
200    /// so is naming its `liveness_source` (DESIGN.md §8): only the code that
201    /// launched knows whether the pid it holds is the work or a launcher.
202    pub fn record_dispatch(
203        &mut self,
204        task_id: i64,
205        agent: &str,
206        pid: Option<i64>,
207        liveness_source: LivenessSource,
208        log_path: Option<&str>,
209    ) -> Result<(Task, Session)> {
210        let tx = self.conn.transaction()?;
211        reject_human_dispatch(&tx, task_id)?;
212        reject_archived_dispatch(&tx, task_id)?;
213        apply_action(&tx, task_id, Action::Start)?;
214        let session_id = insert_session(&tx, task_id, agent, pid, liveness_source, log_path)?;
215        tx.commit()?;
216        Ok((self.task(task_id)?, self.session(session_id)?))
217    }
218
219    /// Refine's atomic write (DESIGN.md §6), the shape [`record_dispatch`]
220    /// established: move the task `proposed | ready → refining` and open the
221    /// round's session in one transaction, so a refining task always has a
222    /// session to probe and a refine session always names a task that is
223    /// refining. A round launched from `ready` still concludes to `proposed`,
224    /// which is what sends the rewritten body back through triage. The
225    /// note rides the transition; the empty string is the interactive flavour,
226    /// which is a conversation rather than a brief. Spawning the process is the
227    /// caller's job, before this commits, and the flavour it spawned rides in as
228    /// `liveness_source`: the headless round inherits the `dispatch` template's
229    /// source, the interactive one is the foreground child's own pid.
230    ///
231    /// [`record_dispatch`]: Store::record_dispatch
232    pub fn record_refine_launch(
233        &mut self,
234        task_id: i64,
235        note: &str,
236        agent: &str,
237        pid: Option<i64>,
238        liveness_source: LivenessSource,
239        log_path: Option<&str>,
240    ) -> Result<(Task, Session)> {
241        let tx = self.conn.transaction()?;
242        apply_action(&tx, task_id, Action::Refine(note.to_string()))?;
243        let session_id = insert_session(&tx, task_id, agent, pid, liveness_source, log_path)?;
244        tx.commit()?;
245        Ok((self.task(task_id)?, self.session(session_id)?))
246    }
247
248    /// End a refine round (DESIGN.md §6): `refining → proposed`, logging how it
249    /// ended and closing the round's session with the matching outcome. Every
250    /// trigger comes through here — the agent's own `set --body-file`
251    /// (`Applied`), a reconciled dead agent (`Failed`), a quit or cancelled
252    /// session (`Cancelled`) — so the returned proposal's markers read from one
253    /// place. The landing is `proposed` whatever the round started from: the
254    /// round keeps no memory of its origin, so a task refined out of `ready`
255    /// comes back for a fresh verdict on the body that replaced the one the old
256    /// verdict was issued against. Refused on a task that is not refining, like
257    /// any other transition.
258    pub fn conclude_refine(&mut self, task_id: i64, outcome: RefineOutcome) -> Result<Task> {
259        self.apply(task_id, Action::ConcludeRefine(outcome))
260    }
261
262    /// Reconcile an open session against its task's state (DESIGN.md §8). The
263    /// session's life follows the task, not the process listing, so the terminal
264    /// transitions close healthy sessions; reconciliation only catches a crash
265    /// or cap mid-`running` and finalises sessions stranded on already-closed
266    /// tasks. `pid_alive`/`likely_capped` are supplied by the caller (voro-core
267    /// does no process or log I/O) and matter only for a `running` task:
268    ///
269    /// - session already ended: no-op (`Ok(None)`), so a repeated sweep can't
270    ///   double-finalise it.
271    /// - `running`, `pid_alive`: left untouched.
272    /// - `running`, process gone: outcome recorded (`capped`/`failed`) and the
273    ///   task goes `running → stalled` (DESIGN.md §6/§8) — an attention row never
274    ///   handed out by `voro next`; a late `done` lands it in `review` on the
275    ///   dead session's behalf. A stalled task with an open blocker demotes to
276    ///   `parked`.
277    /// - `refining`, process gone: the agent died without rewriting anything, so
278    ///   the round concludes `failed` and the task goes back to `proposed`
279    ///   carrying the failed-round marker (DESIGN.md §6).
280    /// - `needs-input`/`review`/`waiting`: the session stays open on purpose
281    ///   (the operator answers in it, or a reject returns the work to it), so
282    ///   this leaves it alone (`Ok(None)`) regardless of liveness.
283    /// - task already closed or off the active path: the session is stale, so it
284    ///   is finalised now (`completed` for `done`, else `aborted`) with no event.
285    pub fn reconcile_session(
286        &mut self,
287        session_id: i64,
288        pid_alive: bool,
289        likely_capped: bool,
290    ) -> Result<Option<(Session, Task)>> {
291        let tx = self.conn.transaction()?;
292        let session = get_session(&tx, session_id)?.ok_or(Error::SessionNotFound(session_id))?;
293        if session.ended_at.is_some() {
294            return Ok(None);
295        }
296        let task = get_task(&tx, session.task_id)?.ok_or(Error::TaskNotFound(session.task_id))?;
297
298        let outcome = match task.state {
299            // A refine round's agent is probed exactly like a dispatch's: gone
300            // means the body was never rewritten (DESIGN.md §6).
301            TaskState::Running | TaskState::Refining => {
302                if pid_alive {
303                    return Ok(None);
304                }
305                if likely_capped {
306                    SessionOutcome::Capped
307                } else {
308                    SessionOutcome::Failed
309                }
310            }
311            // The session is meant to stay open here; nothing to reconcile.
312            // `waiting` keeps it open like `review` so a reject-with-feedback
313            // can continue the same agent session (DESIGN.md §8).
314            TaskState::NeedsInput | TaskState::Review | TaskState::Waiting => return Ok(None),
315            // Stale: a session still open on a task that has left the active
316            // path. Close it with the outcome that fits where the task landed.
317            TaskState::Done => SessionOutcome::Completed,
318            TaskState::Rejected
319            | TaskState::Ready
320            | TaskState::Parked
321            | TaskState::Proposed
322            | TaskState::Stalled => SessionOutcome::Aborted,
323        };
324        set_session_outcome(&tx, session_id, outcome)?;
325
326        if matches!(task.state, TaskState::Running | TaskState::Refining) {
327            log_event(
328                &tx,
329                task.id,
330                "reconcile",
331                Some(&format!(
332                    "session {session_id} ended without reporting ({outcome})"
333                )),
334            )?;
335        }
336        if task.state == TaskState::Running {
337            tx.execute(
338                "UPDATE tasks SET state = ?1, state_since = datetime('now') WHERE id = ?2",
339                params![TaskState::Stalled, task.id],
340            )?;
341            log_event(
342                &tx,
343                task.id,
344                "transition",
345                Some(&format!("{} -> {}", task.state, TaskState::Stalled)),
346            )?;
347            reconcile_readiness(&tx, task.id)?;
348        }
349        // The refine round has a transition of its own, so it goes through the
350        // machine rather than a raw update; the session is already stamped
351        // above, so the conclusion's close finds nothing left open.
352        if task.state == TaskState::Refining {
353            apply_action(
354                &tx,
355                task.id,
356                Action::ConcludeRefine(crate::model::RefineOutcome::Failed),
357            )?;
358        }
359        tx.commit()?;
360        Ok(Some((
361            self.session(session_id)?,
362            self.task(session.task_id)?,
363        )))
364    }
365
366    /// Replace the `blocks` dependencies of a task with the given set, then
367    /// reconcile its readiness. This is the dep-editing entry point for
368    /// interfaces; `add_dep`/`remove_dep` reconcile too. A repeated id in
369    /// `depends_on` names the same single edge, so the set is deduplicated
370    /// rather than left to collide on insert.
371    pub fn set_blocks_deps(&mut self, task_id: i64, depends_on: &[i64]) -> Result<Task> {
372        let tx = self.conn.transaction()?;
373        if get_task(&tx, task_id)?.is_none() {
374            return Err(Error::TaskNotFound(task_id));
375        }
376        tx.execute(
377            "DELETE FROM deps WHERE task_id = ?1 AND kind = 'blocks'",
378            [task_id],
379        )?;
380        let mut seen = HashSet::new();
381        for dep in depends_on.iter().filter(|dep| seen.insert(**dep)) {
382            if get_task(&tx, *dep)?.is_none() {
383                return Err(Error::TaskNotFound(*dep));
384            }
385            reject_blocks_cycle(&tx, task_id, *dep)?;
386            tx.execute(
387                "INSERT INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, 'blocks')",
388                params![task_id, dep],
389            )?;
390        }
391        reconcile_readiness(&tx, task_id)?;
392        tx.commit()?;
393        self.task(task_id)
394    }
395
396    /// The reverse authoring direction of
397    /// [`set_blocks_deps`](Store::set_blocks_deps): make `blocker_id` a blocker
398    /// of each task in `dependents`. Additive and idempotent (replacing the set
399    /// here would detach edges other tasks authored) — the conflict clause is
400    /// scoped to the identical edge, so an edge of another kind between the same
401    /// pair coexists with the blocker rather than swallowing it. Each dependent's
402    /// readiness is reconciled in the same write; the returned pairs carry its
403    /// prior state so callers can surface a demotion.
404    pub fn block_tasks(
405        &mut self,
406        blocker_id: i64,
407        dependents: &[i64],
408    ) -> Result<Vec<(Task, TaskState)>> {
409        let tx = self.conn.transaction()?;
410        if get_task(&tx, blocker_id)?.is_none() {
411            return Err(Error::TaskNotFound(blocker_id));
412        }
413        let mut affected = Vec::with_capacity(dependents.len());
414        for dep in dependents {
415            let before = get_task(&tx, *dep)?.ok_or(Error::TaskNotFound(*dep))?.state;
416            reject_blocks_cycle(&tx, *dep, blocker_id)?;
417            tx.execute(
418                "INSERT INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, 'blocks')
419                 ON CONFLICT (task_id, depends_on, kind) DO NOTHING",
420                params![dep, blocker_id],
421            )?;
422            reconcile_readiness(&tx, *dep)?;
423            affected.push((*dep, before));
424        }
425        tx.commit()?;
426        affected
427            .into_iter()
428            .map(|(id, before)| Ok((self.task(id)?, before)))
429            .collect()
430    }
431}
432
433/// The state machine proper: validate `action` against the task's current
434/// state, restamp `state_since`, maintain the `question`/`closed_at`
435/// invariants, append to the event log, and cascade dependant readiness —
436/// against an already-open transaction so callers can bundle further writes
437/// (a session insert, for dispatch) into the same atomic unit.
438fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result<TaskState> {
439    let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
440
441    use TaskState::*;
442    let to = match (task.state, &action) {
443        (Proposed, Action::Triage(Triage::Parked)) => Parked,
444        (Proposed, Action::Triage(Triage::Ready)) => Ready,
445        (Proposed, Action::Triage(Triage::Reject)) => Rejected,
446        // A refine round: out of the queue while an agent rewrites the body,
447        // back to `proposed` for a real verdict when it concludes — from
448        // `ready` as much as from `proposed`, since a verdict issued against a
449        // body that no longer exists has to be reissued (DESIGN.md §6).
450        (Proposed | Ready, Action::Refine(_)) => Refining,
451        (Refining, Action::ConcludeRefine(_)) => Proposed,
452        (Ready | Stalled, Action::Start) => Running,
453        // A human task cannot be blocked on a decision — the executor *is* the
454        // human (DESIGN.md §6).
455        (Running, Action::Ask(_)) if task.human => {
456            return Err(Error::HumanTask {
457                id: task_id,
458                reason: "its executor is the human, who cannot be blocked on their own \
459                         decision — file a follow-up task that blocks on this one instead"
460                    .into(),
461            });
462        }
463        (Running, Action::Ask(_)) => NeedsInput,
464        (NeedsInput, Action::Resume) => Running,
465        // Completing a human task skips `review`: the human is both executor
466        // and acceptor, so there is no one left to accept the work (§6).
467        (Running | Stalled, Action::Complete(_)) if task.human => Done,
468        // From `stalled`, completion reports a dead session's finished work on
469        // its behalf — the misfire case (§8). The session is already closed, so
470        // only the state moves.
471        (Running | Stalled, Action::Complete(_)) => Review,
472        // Hand a finished review off to an external party and pull it back —
473        // `waiting` asks nothing of the operator while it is someone else's
474        // move (DESIGN.md §6). Only from `review` for now.
475        (Review, Action::HandOff) => Waiting,
476        (Waiting, Action::Reclaim) => Review,
477        (Review | Waiting, Action::Accept) => Done,
478        (Review | Waiting, Action::RejectWork(_)) => Running,
479        (Running, Action::Abort) => Ready,
480        (Ready | Stalled, Action::Park) => Parked,
481        (Parked, Action::Unpark) => Ready,
482        (Parked | Ready | NeedsInput | Review | Waiting | Stalled, Action::Abandon) => Rejected,
483        _ => {
484            return Err(Error::InvalidTransition {
485                from: task.state,
486                action: action.name().to_string(),
487            });
488        }
489    };
490
491    match &action {
492        Action::Ask(q) if q.trim().is_empty() => {
493            return Err(Error::Invalid("a question is required".into()));
494        }
495        Action::RejectWork(f) if f.trim().is_empty() => {
496            return Err(Error::Invalid("rejection feedback is required".into()));
497        }
498        _ => {}
499    }
500    let question = match &action {
501        Action::Ask(q) => Some(q.trim().to_string()),
502        _ => None,
503    };
504
505    tx.execute(
506        "UPDATE tasks SET state = ?1, state_since = datetime('now'), question = ?2,
507                closed_at = CASE WHEN ?3 THEN datetime('now') ELSE closed_at END
508         WHERE id = ?4",
509        params![to, question, to.is_terminal(), task_id],
510    )?;
511    log_event(
512        tx,
513        task_id,
514        "transition",
515        Some(&format!("{} -> {}", task.state, to)),
516    )?;
517
518    match &action {
519        Action::RejectWork(f) => {
520            append_section(tx, task_id, "Feedback", f.trim())?;
521            log_event(tx, task_id, "feedback", Some(f.trim()))?;
522        }
523        Action::Complete(Some(s)) if !s.trim().is_empty() => {
524            log_event(tx, task_id, "summary", Some(s.trim()))?;
525        }
526        // The operator's note rides the launch, exactly as a completion summary
527        // rides `done`; the interactive flavour carries none, since the brief
528        // is the conversation itself.
529        Action::Refine(note) if !note.trim().is_empty() => {
530            log_event(tx, task_id, "refined", Some(note.trim()))?;
531        }
532        // How the round ended, which is what the markers on the returned
533        // proposal are derived from (DESIGN.md §6).
534        Action::ConcludeRefine(outcome) => {
535            log_event(tx, task_id, "refine", Some(outcome.as_str()))?;
536        }
537        _ => {}
538    }
539
540    // The session's life follows the task (DESIGN.md §8): terminal transitions
541    // close the task's open session in the same transaction, while
542    // Ask/Resume/Complete/RejectWork deliberately leave it open — so the
543    // operator can answer a question, or address rejection feedback, in the same
544    // agent session across needs-input/review.
545    match &action {
546        Action::Accept => {
547            close_open_session(tx, task_id, SessionOutcome::Completed)?;
548        }
549        // A human completion is itself terminal (running → done), so it owns the
550        // teardown; the close is belt-and-braces, since no agent session should
551        // exist on a human task.
552        Action::Complete(_) if to == TaskState::Done => {
553            close_open_session(tx, task_id, SessionOutcome::Completed)?;
554        }
555        Action::Abort | Action::Abandon => {
556            close_open_session(tx, task_id, SessionOutcome::Aborted)?;
557        }
558        // A concluded refine tears its round down whichever trigger fired
559        // (DESIGN.md §6): the agent's own `set --body-file` lands here as much
560        // as a cancel does, so all four triggers close the session identically.
561        Action::ConcludeRefine(outcome) => {
562            close_open_session(tx, task_id, outcome.session_outcome())?;
563        }
564        _ => {}
565    }
566
567    if to.is_terminal() {
568        reconcile_dependants(tx, task_id)?;
569    } else if to == TaskState::Ready {
570        // `ready` must mean genuinely actionable: a transition landing here with
571        // a blocker still open (triage, abort, unpark) reconciles back to
572        // `parked`.
573        reconcile_readiness(tx, task_id)?;
574    }
575
576    Ok(to)
577}
578
579/// Refuse to open an agent session on a human-only task (DESIGN.md §6/§8):
580/// dispatch and redispatch both route through here, before any state change or
581/// session insert, so the refusal writes nothing.
582fn reject_human_dispatch(tx: &Connection, task_id: i64) -> Result<()> {
583    let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?;
584    if task.human {
585        return Err(Error::HumanTask {
586            id: task_id,
587            reason: "no agent can execute it — start it by hand instead".into(),
588        });
589    }
590    Ok(())
591}
592
593/// Refuse to open an agent session on a task in an archived project
594/// (DESIGN.md §5): the project has left the cockpit, so dispatch and redispatch
595/// are both side doors. Like the human guard, this runs before any state change
596/// or session insert, so the refusal writes nothing.
597fn reject_archived_dispatch(tx: &Connection, task_id: i64) -> Result<()> {
598    let (name, archived): (String, bool) = tx.query_row(
599        "SELECT p.name, p.archived FROM projects p
600         JOIN tasks t ON t.project_id = p.id WHERE t.id = ?1",
601        [task_id],
602        |r| Ok((r.get(0)?, r.get(1)?)),
603    )?;
604    if archived {
605        return Err(Error::ProjectArchived { name });
606    }
607    Ok(())
608}
609
610/// Reject `from` acquiring a `blocks` dependency on `to` if doing so would
611/// close a cycle in the `blocks` graph (a task blocking itself, directly or
612/// transitively). Called by every write path that adds a `blocks` edge.
613pub(crate) fn reject_blocks_cycle(conn: &Connection, from: i64, to: i64) -> Result<()> {
614    if let Some(cycle) = find_blocks_cycle(conn, from, to)? {
615        let path = cycle
616            .iter()
617            .map(i64::to_string)
618            .collect::<Vec<_>>()
619            .join(" -> ");
620        return Err(Error::DependencyCycle(path));
621    }
622    Ok(())
623}
624
625/// Would adding the edge `from` --blocks--> `to` close a cycle? Equivalent to
626/// asking whether `to` can already reach `from` by following existing
627/// `blocks` edges (`task_id -> depends_on`) — if so, the new edge would let
628/// `from` walk out to `to` and back to itself. Self-deps are the degenerate
629/// case where `from == to`, a zero-hop cycle. Returns the cycle in task-id
630/// order starting and ending at `from`, e.g. `[3, 7, 3]`.
631fn find_blocks_cycle(conn: &Connection, from: i64, to: i64) -> Result<Option<Vec<i64>>> {
632    if from == to {
633        return Ok(Some(vec![from, from]));
634    }
635
636    // BFS outward from `to`, following `blocks` edges, recording each node's
637    // predecessor so a path back to `to` can be rebuilt if `from` turns up.
638    let mut predecessor: HashMap<i64, i64> = HashMap::new();
639    let mut queue = VecDeque::new();
640    queue.push_back(to);
641
642    while let Some(node) = queue.pop_front() {
643        if node == from {
644            let mut path = vec![node];
645            let mut cur = node;
646            while cur != to {
647                cur = predecessor[&cur];
648                path.push(cur);
649            }
650            path.reverse(); // now to -> ... -> from
651            let mut cycle = vec![from];
652            cycle.extend(path);
653            return Ok(Some(cycle));
654        }
655        let mut stmt = conn
656            .prepare_cached("SELECT depends_on FROM deps WHERE task_id = ?1 AND kind = 'blocks'")?;
657        let children: Vec<i64> = stmt
658            .query_map([node], |r| r.get(0))?
659            .collect::<rusqlite::Result<_>>()?;
660        for child in children {
661            if child != to && !predecessor.contains_key(&child) {
662                predecessor.insert(child, node);
663                queue.push_back(child);
664            }
665        }
666    }
667    Ok(None)
668}
669
670/// Append `text` under a `## {heading}` section at the end of the body,
671/// creating the section on first use.
672fn append_section(conn: &Connection, task_id: i64, heading: &str, text: &str) -> Result<()> {
673    let body: String = conn.query_row("SELECT body FROM tasks WHERE id = ?1", [task_id], |r| {
674        r.get(0)
675    })?;
676    let marker = format!("## {heading}");
677    let mut body = body.trim_end().to_string();
678    if !body.ends_with(&marker) && !body.contains(&format!("{marker}\n")) {
679        if !body.is_empty() {
680            body.push_str("\n\n");
681        }
682        body.push_str(&marker);
683        body.push('\n');
684    }
685    body.push_str(&format!("\n- {text}\n"));
686    conn.execute(
687        "UPDATE tasks SET body = ?1 WHERE id = ?2",
688        params![body, task_id],
689    )?;
690    Ok(())
691}
692
693/// After `closed_id` reaches a terminal state, re-check every task that
694/// `blocks`-depends on it (DESIGN.md §5: promotion happens the moment the
695/// last blocker closes).
696fn reconcile_dependants(conn: &Connection, closed_id: i64) -> Result<()> {
697    let mut stmt =
698        conn.prepare("SELECT task_id FROM deps WHERE depends_on = ?1 AND kind = 'blocks'")?;
699    let dependants: Vec<i64> = stmt
700        .query_map([closed_id], |r| r.get(0))?
701        .collect::<rusqlite::Result<_>>()?;
702    for id in dependants {
703        reconcile_readiness(conn, id)?;
704    }
705    Ok(())
706}
707
708/// Enforce readiness against `blocks` dependencies:
709/// - `parked` with at least one blocker, all closed → promote to `ready`.
710///   A parked task with *no* blockers is deliberately parked and stays put.
711/// - `ready` or `stalled` with an open blocker → demote to `parked`. A
712///   stalled task re-promotes to `ready`, not `stalled`, when the blocker
713///   closes — by then the stall context is stale (DESIGN.md §6).
714pub(crate) fn reconcile_readiness(conn: &Connection, task_id: i64) -> Result<()> {
715    let Some(task) = get_task(conn, task_id)? else {
716        return Ok(());
717    };
718    let (total, open): (i64, i64) = conn.query_row(
719        "SELECT COUNT(*),
720                COUNT(*) FILTER (WHERE b.state NOT IN ('done','rejected'))
721         FROM deps d JOIN tasks b ON b.id = d.depends_on
722         WHERE d.task_id = ?1 AND d.kind = 'blocks'",
723        [task_id],
724        |r| Ok((r.get(0)?, r.get(1)?)),
725    )?;
726
727    let to = match task.state {
728        TaskState::Parked if total > 0 && open == 0 => TaskState::Ready,
729        TaskState::Ready | TaskState::Stalled if open > 0 => TaskState::Parked,
730        _ => return Ok(()),
731    };
732    conn.execute(
733        "UPDATE tasks SET state = ?1, state_since = datetime('now') WHERE id = ?2",
734        params![to, task_id],
735    )?;
736    let reason = if to == TaskState::Ready {
737        "unblocked"
738    } else {
739        "blocked"
740    };
741    log_event(
742        conn,
743        task_id,
744        "transition",
745        Some(&format!("{} -> {} ({reason})", task.state, to)),
746    )?;
747    Ok(())
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use crate::model::{DepKind, Priority};
754    use crate::store::NewTask;
755
756    fn store_with_project() -> (Store, i64) {
757        let mut s = Store::open_in_memory().unwrap();
758        let p = s.create_project("proj", "/tmp/proj").unwrap();
759        (s, p.id)
760    }
761
762    fn create(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
763        s.create_task(NewTask {
764            project_id,
765            repo_id: None,
766            title: format!("task in {state}"),
767            body: String::new(),
768            priority: Priority::P1,
769            state,
770            agent: None,
771            human: false,
772            deep: false,
773        })
774        .unwrap()
775        .id
776    }
777
778    /// Walk a fresh task into `state` through the transition API itself.
779    fn task_in_state(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
780        use TaskState::*;
781        match state {
782            Proposed | Parked | Ready => create(s, project_id, state),
783            Refining => {
784                let id = create(s, project_id, Proposed);
785                s.record_refine_launch(
786                    id,
787                    "thin body",
788                    "claude",
789                    Some(1),
790                    LivenessSource::Pid,
791                    None,
792                )
793                .unwrap();
794                id
795            }
796            Running => {
797                let id = create(s, project_id, Ready);
798                s.apply(id, Action::Start).unwrap();
799                id
800            }
801            NeedsInput => {
802                let id = task_in_state(s, project_id, Running);
803                s.apply(id, Action::Ask("which schema?".into())).unwrap();
804                id
805            }
806            Review => {
807                let id = task_in_state(s, project_id, Running);
808                s.apply(id, Action::Complete(None)).unwrap();
809                id
810            }
811            Waiting => {
812                let id = task_in_state(s, project_id, Review);
813                s.apply(id, Action::HandOff).unwrap();
814                id
815            }
816            Stalled => {
817                let id = create(s, project_id, Ready);
818                let (_, session) = s
819                    .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
820                    .unwrap();
821                s.reconcile_session(session.id, false, false).unwrap();
822                id
823            }
824            Done => {
825                let id = task_in_state(s, project_id, Review);
826                s.apply(id, Action::Accept).unwrap();
827                id
828            }
829            Rejected => {
830                let id = create(s, project_id, Proposed);
831                s.apply(id, Action::Triage(Triage::Reject)).unwrap();
832                id
833            }
834        }
835    }
836
837    fn all_actions() -> Vec<Action> {
838        vec![
839            Action::Triage(Triage::Parked),
840            Action::Triage(Triage::Ready),
841            Action::Triage(Triage::Reject),
842            Action::Refine("thin body".into()),
843            Action::ConcludeRefine(RefineOutcome::Applied),
844            Action::Start,
845            Action::Ask("q?".into()),
846            Action::Resume,
847            Action::Complete(None),
848            Action::HandOff,
849            Action::Reclaim,
850            Action::Accept,
851            Action::RejectWork("redo".into()),
852            Action::Abort,
853            Action::Park,
854            Action::Unpark,
855            Action::Abandon,
856        ]
857    }
858
859    /// The full §6 matrix: expected target state for every (state, action)
860    /// pair, `None` meaning the transition is illegal.
861    fn expected(state: TaskState, action: &Action) -> Option<TaskState> {
862        use TaskState::*;
863        match (state, action) {
864            (Proposed, Action::Triage(Triage::Parked)) => Some(Parked),
865            (Proposed, Action::Triage(Triage::Ready)) => Some(Ready),
866            (Proposed, Action::Triage(Triage::Reject)) => Some(Rejected),
867            (Proposed | Ready, Action::Refine(_)) => Some(Refining),
868            (Refining, Action::ConcludeRefine(_)) => Some(Proposed),
869            (Ready | Stalled, Action::Start) => Some(Running),
870            (Ready | Stalled, Action::Park) => Some(Parked),
871            (Parked, Action::Unpark) => Some(Ready),
872            (Running, Action::Ask(_)) => Some(NeedsInput),
873            (Running | Stalled, Action::Complete(_)) => Some(Review),
874            (Running, Action::Abort) => Some(Ready),
875            (NeedsInput, Action::Resume) => Some(Running),
876            (Review, Action::HandOff) => Some(Waiting),
877            (Waiting, Action::Reclaim) => Some(Review),
878            (Review | Waiting, Action::Accept) => Some(Done),
879            (Review | Waiting, Action::RejectWork(_)) => Some(Running),
880            (Parked | Ready | NeedsInput | Review | Waiting | Stalled, Action::Abandon) => {
881                Some(Rejected)
882            }
883            _ => None,
884        }
885    }
886
887    #[test]
888    fn full_transition_matrix() {
889        for state in TaskState::ALL {
890            for action in all_actions() {
891                let (mut s, p) = store_with_project();
892                let id = task_in_state(&mut s, p, state);
893                let result = s.apply(id, action.clone());
894                match expected(state, &action) {
895                    Some(to) => {
896                        let task = result.unwrap_or_else(|e| {
897                            panic!("{state} + {action:?} should reach {to}: {e}")
898                        });
899                        assert_eq!(task.state, to, "{state} + {action:?}");
900                    }
901                    None => {
902                        assert!(
903                            matches!(result, Err(Error::InvalidTransition { .. })),
904                            "{state} + {action:?} should be rejected"
905                        );
906                    }
907                }
908            }
909        }
910    }
911
912    /// `legal_actions` is the transition *menu*, so it matches `apply` on every
913    /// action but one: launching a refine is a legal transition the menu
914    /// deliberately withholds, because that menu collects verdicts and refine is
915    /// not one (DESIGN.md §6) — it answers from its own key over the row.
916    #[test]
917    fn legal_actions_agrees_with_apply() {
918        for state in TaskState::ALL {
919            let legal = Store::legal_actions(state, false);
920            for action in all_actions() {
921                if matches!(action, Action::Refine(_)) {
922                    assert!(
923                        !legal.iter().any(|l| matches!(l, Action::Refine(_))),
924                        "the verdict menu must not offer refine ({state})"
925                    );
926                    continue;
927                }
928                let in_legal = legal
929                    .iter()
930                    .any(|l| std::mem::discriminant(l) == std::mem::discriminant(&action))
931                    && match (&action, state) {
932                        // Triage variants share a discriminant; all are legal
933                        // exactly when the state is proposed.
934                        (Action::Triage(_), s) => s == TaskState::Proposed,
935                        // As do the refine outcomes, all legal exactly when a
936                        // round is in flight — `legal_actions` offers the one
937                        // the operator can pick, which is the cancel.
938                        (Action::ConcludeRefine(_), s) => s == TaskState::Refining,
939                        _ => true,
940                    };
941                assert_eq!(
942                    expected(state, &action).is_some(),
943                    in_legal,
944                    "legal_actions disagrees for {state} + {action:?}"
945                );
946            }
947        }
948    }
949
950    // --- refine rounds (DESIGN.md §6): where they start and where they land ---
951
952    mod refine {
953        use super::*;
954
955        /// The note-driven and interactive flavours are one action carrying
956        /// different notes, so both must open a round from `ready` and both
957        /// must open its session in the same write.
958        #[test]
959        fn a_ready_task_can_be_refined_in_either_flavour() {
960            for note in ["the body names no files", ""] {
961                let (mut s, p) = store_with_project();
962                let id = create(&mut s, p, TaskState::Ready);
963
964                let (task, session) = s
965                    .record_refine_launch(id, note, "claude", Some(4242), LivenessSource::Pid, None)
966                    .unwrap();
967                assert_eq!(task.state, TaskState::Refining);
968                assert_eq!(session.task_id, id);
969                assert!(session.ended_at.is_none());
970                assert!(
971                    s.events_for(id)
972                        .unwrap()
973                        .iter()
974                        .any(|e| e.detail.as_deref() == Some("ready -> refining")),
975                    "the transition is logged"
976                );
977            }
978        }
979
980        /// However the round ends, and whichever state it started from, it
981        /// lands on `proposed`: the verdict a `ready` task already carried was
982        /// issued against a body that no longer exists (DESIGN.md §6).
983        #[test]
984        fn every_round_concludes_to_proposed_whatever_it_started_from() {
985            for from in [TaskState::Proposed, TaskState::Ready] {
986                for outcome in [
987                    RefineOutcome::Applied,
988                    RefineOutcome::Failed,
989                    RefineOutcome::Cancelled,
990                ] {
991                    let (mut s, p) = store_with_project();
992                    let id = create(&mut s, p, from);
993                    s.record_refine_launch(
994                        id,
995                        "thin body",
996                        "claude",
997                        Some(1),
998                        LivenessSource::Pid,
999                        None,
1000                    )
1001                    .unwrap();
1002
1003                    let task = s.conclude_refine(id, outcome).unwrap();
1004                    assert_eq!(task.state, TaskState::Proposed, "{from} + {outcome:?}");
1005                }
1006            }
1007        }
1008
1009        /// `parked` is deliberately outside the widening, and so is every state
1010        /// past triage — a refine rewrites a brief, and by `running` the brief
1011        /// is already being worked.
1012        #[test]
1013        fn refine_is_refused_everywhere_but_proposed_and_ready() {
1014            for state in TaskState::ALL {
1015                if matches!(state, TaskState::Proposed | TaskState::Ready) {
1016                    continue;
1017                }
1018                let (mut s, p) = store_with_project();
1019                let id = task_in_state(&mut s, p, state);
1020                let result = s.apply(id, Action::Refine("thin body".into()));
1021                assert!(
1022                    matches!(result, Err(Error::InvalidTransition { .. })),
1023                    "refine from {state} should be refused"
1024                );
1025            }
1026        }
1027
1028        /// The dispatch race is closed by the state rather than by a guard
1029        /// (DESIGN.md §6): a `ready` task under refinement is not in the
1030        /// scheduler's input at all, so no window can hand it out while its
1031        /// body is being rewritten.
1032        #[test]
1033        fn a_refining_task_leaves_the_ready_work_queue() {
1034            let (mut s, p) = store_with_project();
1035            s.set_weight(p, 3).unwrap();
1036            let id = create(&mut s, p, TaskState::Ready);
1037            assert!(crate::scheduler::focus(&s.candidates().unwrap()).is_some());
1038
1039            s.record_refine_launch(
1040                id,
1041                "thin body",
1042                "claude",
1043                Some(1),
1044                LivenessSource::Pid,
1045                None,
1046            )
1047            .unwrap();
1048            let candidates = s.candidates().unwrap();
1049            assert!(
1050                !candidates.iter().any(|c| c.task.id == id),
1051                "a refining task is not a scheduler candidate"
1052            );
1053            assert!(crate::scheduler::focus(&candidates).is_none());
1054        }
1055    }
1056
1057    // --- human-only tasks (DESIGN.md §3/§6): the shortened path ---
1058
1059    mod human {
1060        use super::*;
1061
1062        fn create_human(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
1063            s.create_task(NewTask {
1064                project_id,
1065                repo_id: None,
1066                title: format!("human task in {state}"),
1067                body: String::new(),
1068                priority: Priority::P1,
1069                state,
1070                agent: None,
1071                human: true,
1072                deep: false,
1073            })
1074            .unwrap()
1075            .id
1076        }
1077
1078        #[test]
1079        fn completion_goes_straight_to_done() {
1080            let (mut s, p) = store_with_project();
1081            let id = create_human(&mut s, p, TaskState::Ready);
1082            s.apply(id, Action::Start).unwrap();
1083
1084            let task = s
1085                .apply(id, Action::Complete(Some("bag captured".into())))
1086                .unwrap();
1087            assert_eq!(task.state, TaskState::Done);
1088            assert!(task.closed_at.is_some());
1089
1090            let events = s.events_for(id).unwrap();
1091            assert!(
1092                events
1093                    .iter()
1094                    .any(|e| e.detail.as_deref() == Some("running -> done")),
1095                "{events:?}"
1096            );
1097            // the summary still rides the completion, as on the agent path
1098            assert!(
1099                events
1100                    .iter()
1101                    .any(|e| e.kind == "summary" && e.detail.as_deref() == Some("bag captured"))
1102            );
1103        }
1104
1105        #[test]
1106        fn ask_is_refused_and_writes_nothing() {
1107            let (mut s, p) = store_with_project();
1108            let id = create_human(&mut s, p, TaskState::Ready);
1109            s.apply(id, Action::Start).unwrap();
1110
1111            let err = s.apply(id, Action::Ask("which bag?".into())).unwrap_err();
1112            assert!(
1113                matches!(err, Error::HumanTask { id: e, .. } if e == id),
1114                "expected a human-only refusal, got {err}"
1115            );
1116            let task = s.task(id).unwrap();
1117            assert_eq!(task.state, TaskState::Running);
1118            assert!(task.question.is_none());
1119        }
1120
1121        #[test]
1122        fn record_dispatch_is_refused_and_writes_nothing() {
1123            let (mut s, p) = store_with_project();
1124            let id = create_human(&mut s, p, TaskState::Ready);
1125
1126            let err = s
1127                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1128                .unwrap_err();
1129            assert!(
1130                matches!(err, Error::HumanTask { id: e, .. } if e == id),
1131                "expected a human-only refusal, got {err}"
1132            );
1133            assert_eq!(s.task(id).unwrap().state, TaskState::Ready);
1134            assert!(s.sessions_for(id).unwrap().is_empty());
1135        }
1136
1137        #[test]
1138        fn completion_unblocks_dependants() {
1139            // running → done is terminal, so it must cascade readiness exactly
1140            // as an accept does.
1141            let (mut s, p) = store_with_project();
1142            let blocker = create_human(&mut s, p, TaskState::Ready);
1143            let dependant = create(&mut s, p, TaskState::Parked);
1144            s.add_dep(dependant, blocker, DepKind::Blocks).unwrap();
1145
1146            s.apply(blocker, Action::Start).unwrap();
1147            s.apply(blocker, Action::Complete(None)).unwrap();
1148            assert_eq!(s.task(dependant).unwrap().state, TaskState::Ready);
1149        }
1150
1151        #[test]
1152        fn completion_closes_a_stray_open_session() {
1153            // No agent session should ever exist on a human task, but the
1154            // terminal completion still tears one down (sessions follow the
1155            // task, §8) if a legacy or hand-made row is lying around.
1156            let (mut s, p) = store_with_project();
1157            let id = create_human(&mut s, p, TaskState::Ready);
1158            s.apply(id, Action::Start).unwrap();
1159            let stray = s
1160                .create_session(id, "claude", Some(1), LivenessSource::Pid, None)
1161                .unwrap();
1162
1163            s.apply(id, Action::Complete(None)).unwrap();
1164            let closed = s.session(stray.id).unwrap();
1165            assert!(closed.ended_at.is_some());
1166            assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1167        }
1168
1169        #[test]
1170        fn legal_actions_omit_ask_and_agree_with_apply() {
1171            let legal = Store::legal_actions(TaskState::Running, true);
1172            assert_eq!(legal, vec![Action::Complete(None), Action::Abort]);
1173            // every other state offers the same menu regardless of the flag
1174            for state in TaskState::ALL {
1175                if state != TaskState::Running {
1176                    assert_eq!(
1177                        Store::legal_actions(state, true),
1178                        Store::legal_actions(state, false),
1179                        "{state}"
1180                    );
1181                }
1182            }
1183        }
1184
1185        /// The matrix over the states a human task can actually reach —
1186        /// `needs-input` and `review` are unreachable by construction (§6).
1187        #[test]
1188        fn full_transition_matrix_for_human_tasks() {
1189            use TaskState::*;
1190            for state in [Proposed, Parked, Ready, Running] {
1191                for action in all_actions() {
1192                    let (mut s, p) = store_with_project();
1193                    let id = create_human(&mut s, p, if state == Running { Ready } else { state });
1194                    if state == Running {
1195                        s.apply(id, Action::Start).unwrap();
1196                    }
1197                    let result = s.apply(id, action.clone());
1198                    let expected = match (state, &action) {
1199                        // the two divergences from the agent path
1200                        (Running, Action::Ask(_)) => None,
1201                        (Running, Action::Complete(_)) => Some(Done),
1202                        _ => expected(state, &action),
1203                    };
1204                    match expected {
1205                        Some(to) => {
1206                            let task = result.unwrap_or_else(|e| {
1207                                panic!("human {state} + {action:?} should reach {to}: {e}")
1208                            });
1209                            assert_eq!(task.state, to, "human {state} + {action:?}");
1210                        }
1211                        None => {
1212                            assert!(
1213                                matches!(
1214                                    result,
1215                                    Err(Error::InvalidTransition { .. } | Error::HumanTask { .. })
1216                                ),
1217                                "human {state} + {action:?} should be rejected"
1218                            );
1219                        }
1220                    }
1221                }
1222            }
1223        }
1224    }
1225
1226    #[test]
1227    fn transitions_restamp_state_since_and_log_events() {
1228        let (mut s, p) = store_with_project();
1229        let id = create(&mut s, p, TaskState::Ready);
1230        s.conn
1231            .execute(
1232                "UPDATE tasks SET state_since = '2000-01-01 00:00:00' WHERE id = ?1",
1233                [id],
1234            )
1235            .unwrap();
1236        let task = s.apply(id, Action::Start).unwrap();
1237        assert_ne!(task.state_since, "2000-01-01 00:00:00");
1238        let events = s.events_for(id).unwrap();
1239        assert_eq!(events.last().unwrap().kind, "transition");
1240        assert_eq!(
1241            events.last().unwrap().detail.as_deref(),
1242            Some("ready -> running")
1243        );
1244    }
1245
1246    #[test]
1247    fn question_is_set_iff_needs_input() {
1248        let (mut s, p) = store_with_project();
1249        let id = task_in_state(&mut s, p, TaskState::Running);
1250        let task = s.apply(id, Action::Ask("  A or B?  ".into())).unwrap();
1251        assert_eq!(task.question.as_deref(), Some("A or B?"));
1252        let task = s.apply(id, Action::Resume).unwrap();
1253        assert_eq!(task.state, TaskState::Running);
1254        assert!(task.question.is_none());
1255
1256        let id = task_in_state(&mut s, p, TaskState::NeedsInput);
1257        let task = s.apply(id, Action::Abandon).unwrap();
1258        assert!(task.question.is_none());
1259    }
1260
1261    #[test]
1262    fn empty_question_and_feedback_are_rejected() {
1263        let (mut s, p) = store_with_project();
1264        let running = task_in_state(&mut s, p, TaskState::Running);
1265        assert!(s.apply(running, Action::Ask("  ".into())).is_err());
1266        let review = task_in_state(&mut s, p, TaskState::Review);
1267        assert!(s.apply(review, Action::RejectWork(" ".into())).is_err());
1268        // a failed apply must not have changed anything
1269        assert_eq!(s.task(review).unwrap().state, TaskState::Review);
1270    }
1271
1272    /// `resume` moves needs-input → running without recording any answer text —
1273    /// the exchange lives in the session transcript (DESIGN.md §6/§8), so the
1274    /// body is untouched and only a transition event is logged.
1275    #[test]
1276    fn resume_records_no_answer_and_leaves_the_body_untouched() {
1277        let (mut s, p) = store_with_project();
1278        let id = task_in_state(&mut s, p, TaskState::NeedsInput);
1279        let before = s.task(id).unwrap().body;
1280
1281        let task = s.apply(id, Action::Resume).unwrap();
1282        assert_eq!(task.state, TaskState::Running);
1283        assert_eq!(task.body, before, "resume must not append to the body");
1284        assert!(task.question.is_none());
1285
1286        let events = s.events_for(id).unwrap();
1287        assert!(
1288            events.iter().all(|e| e.kind != "answer"),
1289            "no answer event is logged: {events:?}"
1290        );
1291        assert_eq!(
1292            events.last().unwrap().detail.as_deref(),
1293            Some("needs-input -> running")
1294        );
1295    }
1296
1297    /// `resume` is refused from every state but `needs-input`.
1298    #[test]
1299    fn resume_is_refused_outside_needs_input() {
1300        use TaskState::*;
1301        for state in [Ready, Running, Review, Waiting, Stalled] {
1302            let (mut s, p) = store_with_project();
1303            let id = task_in_state(&mut s, p, state);
1304            assert!(
1305                matches!(
1306                    s.apply(id, Action::Resume),
1307                    Err(Error::InvalidTransition { .. })
1308                ),
1309                "resume should be refused from {state}"
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn feedback_accumulates_in_body() {
1316        let (mut s, p) = store_with_project();
1317        let id = task_in_state(&mut s, p, TaskState::Review);
1318        let task = s
1319            .apply(id, Action::RejectWork("tests missing".into()))
1320            .unwrap();
1321        assert!(task.body.contains("## Feedback"));
1322        assert!(task.body.contains("- tests missing"));
1323
1324        s.apply(id, Action::Complete(None)).unwrap();
1325        let task = s.apply(id, Action::RejectWork("also lint".into())).unwrap();
1326        assert_eq!(task.body.matches("## Feedback").count(), 1);
1327        assert!(task.body.contains("- also lint"));
1328        assert_eq!(
1329            s.events_for(id)
1330                .unwrap()
1331                .iter()
1332                .filter(|e| e.kind == "feedback")
1333                .count(),
1334            2
1335        );
1336    }
1337
1338    #[test]
1339    fn complete_summary_is_logged_as_an_event() {
1340        let (mut s, p) = store_with_project();
1341        let id = task_in_state(&mut s, p, TaskState::Running);
1342        s.apply(
1343            id,
1344            Action::Complete(Some("Implemented X, tests pass".into())),
1345        )
1346        .unwrap();
1347        let summaries: Vec<_> = s
1348            .events_for(id)
1349            .unwrap()
1350            .into_iter()
1351            .filter(|e| e.kind == "summary")
1352            .collect();
1353        assert_eq!(summaries.len(), 1);
1354        assert_eq!(
1355            summaries[0].detail.as_deref(),
1356            Some("Implemented X, tests pass")
1357        );
1358    }
1359
1360    #[test]
1361    fn complete_without_summary_logs_no_summary_event() {
1362        let (mut s, p) = store_with_project();
1363        let id = task_in_state(&mut s, p, TaskState::Running);
1364        s.apply(id, Action::Complete(None)).unwrap();
1365        let blank = task_in_state(&mut s, p, TaskState::Running);
1366        s.apply(blank, Action::Complete(Some("   ".into())))
1367            .unwrap();
1368        assert!(
1369            s.events_for(id)
1370                .unwrap()
1371                .iter()
1372                .all(|e| e.kind != "summary")
1373        );
1374        assert!(
1375            s.events_for(blank)
1376                .unwrap()
1377                .iter()
1378                .all(|e| e.kind != "summary")
1379        );
1380    }
1381
1382    #[test]
1383    fn closing_stamps_closed_at() {
1384        let (mut s, p) = store_with_project();
1385        let id = task_in_state(&mut s, p, TaskState::Done);
1386        assert!(s.task(id).unwrap().closed_at.is_some());
1387        let id = task_in_state(&mut s, p, TaskState::Rejected);
1388        assert!(s.task(id).unwrap().closed_at.is_some());
1389        let id = task_in_state(&mut s, p, TaskState::Review);
1390        assert!(s.task(id).unwrap().closed_at.is_none());
1391    }
1392
1393    #[test]
1394    fn accepting_last_blocker_promotes_dependant() {
1395        let (mut s, p) = store_with_project();
1396        let blocker = task_in_state(&mut s, p, TaskState::Review);
1397        let dependant = create(&mut s, p, TaskState::Parked);
1398        s.add_dep(dependant, blocker, DepKind::Blocks).unwrap();
1399
1400        s.apply(blocker, Action::Accept).unwrap();
1401        let task = s.task(dependant).unwrap();
1402        assert_eq!(task.state, TaskState::Ready);
1403        let events = s.events_for(dependant).unwrap();
1404        assert_eq!(
1405            events.last().unwrap().detail.as_deref(),
1406            Some("parked -> ready (unblocked)")
1407        );
1408    }
1409
1410    #[test]
1411    fn promotion_waits_for_the_last_blocker() {
1412        let (mut s, p) = store_with_project();
1413        let b1 = task_in_state(&mut s, p, TaskState::Review);
1414        let b2 = create(&mut s, p, TaskState::Ready);
1415        let dependant = create(&mut s, p, TaskState::Parked);
1416        s.add_dep(dependant, b1, DepKind::Blocks).unwrap();
1417        s.add_dep(dependant, b2, DepKind::Blocks).unwrap();
1418
1419        s.apply(b1, Action::Accept).unwrap();
1420        assert_eq!(s.task(dependant).unwrap().state, TaskState::Parked);
1421
1422        // a rejected blocker no longer blocks either
1423        s.apply(b2, Action::Abandon).unwrap();
1424        assert_eq!(s.task(dependant).unwrap().state, TaskState::Ready);
1425    }
1426
1427    #[test]
1428    fn parked_task_without_blockers_is_never_auto_promoted() {
1429        let (mut s, p) = store_with_project();
1430        let parked = create(&mut s, p, TaskState::Parked);
1431        let unrelated = task_in_state(&mut s, p, TaskState::Review);
1432        s.apply(unrelated, Action::Accept).unwrap();
1433        assert_eq!(s.task(parked).unwrap().state, TaskState::Parked);
1434    }
1435
1436    #[test]
1437    fn only_blocks_deps_gate_readiness() {
1438        let (mut s, p) = store_with_project();
1439        let other = create(&mut s, p, TaskState::Ready);
1440        let task = create(&mut s, p, TaskState::Ready);
1441        for kind in [DepKind::DiscoveredFrom, DepKind::Parent, DepKind::Related] {
1442            s.add_dep(task, other, kind).unwrap();
1443        }
1444        assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1445    }
1446
1447    /// A task proposed from another and then gated on it: the `discovered-from`
1448    /// edge already occupying the pair must not swallow the blocker.
1449    #[test]
1450    fn set_blocks_deps_coexists_with_a_discovered_from_edge() {
1451        let (mut s, p) = store_with_project();
1452        let source = create(&mut s, p, TaskState::Ready);
1453        let task = create(&mut s, p, TaskState::Ready);
1454        s.add_dep(task, source, DepKind::DiscoveredFrom).unwrap();
1455
1456        let after = s.set_blocks_deps(task, &[source]).unwrap();
1457        assert_eq!(after.state, TaskState::Parked);
1458        let kinds: Vec<DepKind> = s.deps_of(task).unwrap().iter().map(|d| d.kind).collect();
1459        assert_eq!(kinds, vec![DepKind::Blocks, DepKind::DiscoveredFrom]);
1460    }
1461
1462    /// The same collision authored from the blocker's end.
1463    #[test]
1464    fn block_tasks_coexists_with_a_discovered_from_edge() {
1465        let (mut s, p) = store_with_project();
1466        let source = create(&mut s, p, TaskState::Ready);
1467        let task = create(&mut s, p, TaskState::Ready);
1468        s.add_dep(task, source, DepKind::DiscoveredFrom).unwrap();
1469
1470        let affected = s.block_tasks(source, &[task]).unwrap();
1471        assert_eq!(affected[0].0.state, TaskState::Parked);
1472        assert_eq!(affected[0].1, TaskState::Ready);
1473        let kinds: Vec<DepKind> = s.deps_of(task).unwrap().iter().map(|d| d.kind).collect();
1474        assert_eq!(kinds, vec![DepKind::Blocks, DepKind::DiscoveredFrom]);
1475
1476        // still idempotent on the identical edge
1477        s.block_tasks(source, &[task]).unwrap();
1478        assert_eq!(s.deps_of(task).unwrap().len(), 2);
1479    }
1480
1481    /// A repeated id names one edge, not a collision.
1482    #[test]
1483    fn set_blocks_deps_tolerates_a_repeated_id() {
1484        let (mut s, p) = store_with_project();
1485        let blocker = create(&mut s, p, TaskState::Ready);
1486        let task = create(&mut s, p, TaskState::Ready);
1487        s.set_blocks_deps(task, &[blocker, blocker]).unwrap();
1488        assert_eq!(s.deps_of(task).unwrap().len(), 1);
1489    }
1490
1491    #[test]
1492    fn adding_open_blocker_demotes_ready_task() {
1493        let (mut s, p) = store_with_project();
1494        let blocker = create(&mut s, p, TaskState::Ready);
1495        let task = create(&mut s, p, TaskState::Ready);
1496        s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1497        let demoted = s.task(task).unwrap();
1498        assert_eq!(demoted.state, TaskState::Parked);
1499
1500        // ...and closing that blocker brings it straight back.
1501        s.apply(blocker, Action::Start).unwrap();
1502        s.apply(blocker, Action::Complete(None)).unwrap();
1503        s.apply(blocker, Action::Accept).unwrap();
1504        assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1505    }
1506
1507    #[test]
1508    fn triaging_to_ready_parks_a_blocked_task() {
1509        let (mut s, p) = store_with_project();
1510        let blocker = create(&mut s, p, TaskState::Ready);
1511        let task = create(&mut s, p, TaskState::Proposed);
1512        s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1513
1514        // The human triages it "ready", but an open blocker overrides that: it
1515        // lands in parked and never reaches the scheduler.
1516        let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1517        assert_eq!(triaged.state, TaskState::Parked);
1518
1519        // closing the blocker auto-promotes it exactly like any parked task
1520        s.apply(blocker, Action::Start).unwrap();
1521        s.apply(blocker, Action::Complete(None)).unwrap();
1522        s.apply(blocker, Action::Accept).unwrap();
1523        assert_eq!(s.task(task).unwrap().state, TaskState::Ready);
1524    }
1525
1526    #[test]
1527    fn triaging_to_ready_stays_ready_when_unblocked() {
1528        let (mut s, p) = store_with_project();
1529        let closed = task_in_state(&mut s, p, TaskState::Done);
1530        let task = create(&mut s, p, TaskState::Proposed);
1531        s.add_dep(task, closed, DepKind::Blocks).unwrap();
1532        // a closed blocker does not gate readiness
1533        let triaged = s.apply(task, Action::Triage(Triage::Ready)).unwrap();
1534        assert_eq!(triaged.state, TaskState::Ready);
1535    }
1536
1537    #[test]
1538    fn aborting_parks_a_task_blocked_while_running() {
1539        let (mut s, p) = store_with_project();
1540        let task = task_in_state(&mut s, p, TaskState::Running);
1541        let blocker = create(&mut s, p, TaskState::Ready);
1542        // adding a blocker to a running task leaves it running...
1543        s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1544        assert_eq!(s.task(task).unwrap().state, TaskState::Running);
1545        // ...but aborting must not expose it as ready while still blocked
1546        let aborted = s.apply(task, Action::Abort).unwrap();
1547        assert_eq!(aborted.state, TaskState::Parked);
1548    }
1549
1550    #[test]
1551    fn unparking_a_blocked_task_reparks_it() {
1552        let (mut s, p) = store_with_project();
1553        let blocker = create(&mut s, p, TaskState::Ready);
1554        let task = create(&mut s, p, TaskState::Ready);
1555        s.add_dep(task, blocker, DepKind::Blocks).unwrap();
1556        assert_eq!(s.task(task).unwrap().state, TaskState::Parked);
1557        // a manual unpark cannot override an open blocker
1558        let unparked = s.apply(task, Action::Unpark).unwrap();
1559        assert_eq!(unparked.state, TaskState::Parked);
1560    }
1561
1562    #[test]
1563    fn set_blocks_deps_replaces_and_reconciles() {
1564        let (mut s, p) = store_with_project();
1565        let open = create(&mut s, p, TaskState::Ready);
1566        let closed = task_in_state(&mut s, p, TaskState::Done);
1567        let task = create(&mut s, p, TaskState::Ready);
1568
1569        let t = s.set_blocks_deps(task, &[open, closed]).unwrap();
1570        assert_eq!(t.state, TaskState::Parked);
1571
1572        // dropping the open blocker (one closed dep remains) promotes
1573        let t = s.set_blocks_deps(task, &[closed]).unwrap();
1574        assert_eq!(t.state, TaskState::Ready);
1575
1576        assert!(s.set_blocks_deps(task, &[task]).is_err());
1577        assert!(s.set_blocks_deps(task, &[9999]).is_err());
1578    }
1579
1580    #[test]
1581    fn block_tasks_demotes_ready_dependents_in_the_same_write() {
1582        let (mut s, p) = store_with_project();
1583        let blocker = create(&mut s, p, TaskState::Ready);
1584        let ready = create(&mut s, p, TaskState::Ready);
1585        let parked = create(&mut s, p, TaskState::Parked);
1586
1587        let affected = s.block_tasks(blocker, &[ready, parked]).unwrap();
1588        let states: Vec<_> = affected
1589            .iter()
1590            .map(|(t, before)| (t.id, *before, t.state))
1591            .collect();
1592        assert_eq!(
1593            states,
1594            vec![
1595                (ready, TaskState::Ready, TaskState::Parked),
1596                (parked, TaskState::Parked, TaskState::Parked),
1597            ]
1598        );
1599
1600        // closing the blocker promotes both dependents
1601        s.apply(blocker, Action::Start).unwrap();
1602        s.apply(blocker, Action::Complete(None)).unwrap();
1603        s.apply(blocker, Action::Accept).unwrap();
1604        assert_eq!(s.task(ready).unwrap().state, TaskState::Ready);
1605        assert_eq!(s.task(parked).unwrap().state, TaskState::Ready);
1606    }
1607
1608    #[test]
1609    fn block_tasks_is_additive_and_idempotent() {
1610        let (mut s, p) = store_with_project();
1611        let existing = create(&mut s, p, TaskState::Ready);
1612        let blocker = create(&mut s, p, TaskState::Ready);
1613        let task = create(&mut s, p, TaskState::Ready);
1614        s.set_blocks_deps(task, &[existing]).unwrap();
1615
1616        s.block_tasks(blocker, &[task]).unwrap();
1617        s.block_tasks(blocker, &[task]).unwrap();
1618        let deps = s.deps_of(task).unwrap();
1619        assert_eq!(deps.len(), 2, "{deps:?}");
1620    }
1621
1622    #[test]
1623    fn block_tasks_rejects_cycles_and_unknown_tasks() {
1624        let (mut s, p) = store_with_project();
1625        let a = create(&mut s, p, TaskState::Ready);
1626        let b = create(&mut s, p, TaskState::Ready);
1627        let c = create(&mut s, p, TaskState::Ready);
1628        // b waits on a, c waits on b; making c block a would close the loop
1629        s.set_blocks_deps(b, &[a]).unwrap();
1630        s.set_blocks_deps(c, &[b]).unwrap();
1631        let err = s.block_tasks(c, &[a]).unwrap_err();
1632        assert!(matches!(err, Error::DependencyCycle(_)), "{err:?}");
1633
1634        // self-block is the zero-hop cycle
1635        assert!(s.block_tasks(a, &[a]).is_err());
1636        assert!(s.block_tasks(a, &[9999]).is_err());
1637        assert!(s.block_tasks(9999, &[a]).is_err());
1638        assert!(s.deps_of(a).unwrap().is_empty());
1639    }
1640
1641    #[test]
1642    fn record_dispatch_starts_the_task_and_opens_a_session() {
1643        let (mut s, p) = store_with_project();
1644        let id = create(&mut s, p, TaskState::Ready);
1645        let (task, session) = s
1646            .record_dispatch(
1647                id,
1648                "claude",
1649                Some(4321),
1650                LivenessSource::Pid,
1651                Some("/var/log/26.log"),
1652            )
1653            .unwrap();
1654        assert_eq!(task.state, TaskState::Running);
1655        assert_eq!(session.task_id, id);
1656        assert_eq!(session.agent, "claude");
1657        assert_eq!(session.pid, Some(4321));
1658        assert_eq!(session.log_path.as_deref(), Some("/var/log/26.log"));
1659        assert!(session.ended_at.is_none());
1660        assert_eq!(s.sessions_for(id).unwrap().len(), 1);
1661    }
1662
1663    #[test]
1664    fn record_dispatch_on_a_non_ready_task_writes_nothing() {
1665        let (mut s, p) = store_with_project();
1666        let id = create(&mut s, p, TaskState::Proposed);
1667        assert!(matches!(
1668            s.record_dispatch(id, "claude", None, LivenessSource::Pid, None),
1669            Err(Error::InvalidTransition { .. })
1670        ));
1671        // the failed transaction must leave neither state change nor session
1672        assert_eq!(s.task(id).unwrap().state, TaskState::Proposed);
1673        assert!(s.sessions_for(id).unwrap().is_empty());
1674    }
1675
1676    #[test]
1677    fn dispatch_refuses_a_task_in_an_archived_project() {
1678        // An archived project's tasks freeze where they are (DESIGN.md §5):
1679        // dispatch is a side door and must write nothing.
1680        let (mut s, p) = store_with_project();
1681        let ready = create(&mut s, p, TaskState::Ready);
1682        s.set_archived(p, true).unwrap();
1683
1684        let err = s
1685            .record_dispatch(ready, "claude", Some(1), LivenessSource::Pid, None)
1686            .unwrap_err();
1687        assert!(matches!(err, Error::ProjectArchived { .. }), "{err}");
1688        assert_eq!(s.task(ready).unwrap().state, TaskState::Ready);
1689        assert!(s.sessions_for(ready).unwrap().is_empty());
1690
1691        // unarchiving reopens the door
1692        s.set_archived(p, false).unwrap();
1693        assert!(
1694            s.record_dispatch(ready, "claude", Some(1), LivenessSource::Pid, None)
1695                .is_ok()
1696        );
1697    }
1698
1699    // --- session lifecycle: one open session, closed by terminal transitions ---
1700
1701    #[test]
1702    fn terminal_transitions_close_the_open_session_with_the_right_outcome() {
1703        let (mut s, p) = store_with_project();
1704
1705        // Accept: the session is kept open through review, then closed
1706        // `completed` when the review is accepted.
1707        let accepted = create(&mut s, p, TaskState::Ready);
1708        let sess = s
1709            .record_dispatch(accepted, "claude", Some(1), LivenessSource::Pid, None)
1710            .unwrap()
1711            .1;
1712        s.apply(accepted, Action::Complete(None)).unwrap();
1713        assert!(
1714            s.session(sess.id).unwrap().ended_at.is_none(),
1715            "review keeps it open"
1716        );
1717        s.apply(accepted, Action::Accept).unwrap();
1718        let closed = s.session(sess.id).unwrap();
1719        assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1720        assert!(closed.ended_at.is_some());
1721
1722        // Abort: running -> ready closes the session `aborted`.
1723        let aborted = create(&mut s, p, TaskState::Ready);
1724        let sess = s
1725            .record_dispatch(aborted, "claude", Some(1), LivenessSource::Pid, None)
1726            .unwrap()
1727            .1;
1728        s.apply(aborted, Action::Abort).unwrap();
1729        assert_eq!(
1730            s.session(sess.id).unwrap().outcome,
1731            Some(SessionOutcome::Aborted)
1732        );
1733
1734        // Abandon (from review) closes the session `aborted` too.
1735        let abandoned = create(&mut s, p, TaskState::Ready);
1736        let sess = s
1737            .record_dispatch(abandoned, "claude", Some(1), LivenessSource::Pid, None)
1738            .unwrap()
1739            .1;
1740        s.apply(abandoned, Action::Complete(None)).unwrap();
1741        s.apply(abandoned, Action::Abandon).unwrap();
1742        assert_eq!(
1743            s.session(sess.id).unwrap().outcome,
1744            Some(SessionOutcome::Aborted)
1745        );
1746    }
1747
1748    #[test]
1749    fn ask_and_resume_keep_the_session_open_across_needs_input() {
1750        // A question and its answer leave the dispatched session open across
1751        // needs-input -> running, so the operator answers in that same agent
1752        // session and `resume` only moves the state (DESIGN.md §6/§8).
1753        let (mut s, p) = store_with_project();
1754        let id = create(&mut s, p, TaskState::Ready);
1755        let sess = s
1756            .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1757            .unwrap()
1758            .1;
1759
1760        s.apply(id, Action::Ask("A or B?".into())).unwrap();
1761        assert!(s.session(sess.id).unwrap().ended_at.is_none());
1762        s.apply(id, Action::Resume).unwrap();
1763        assert!(
1764            s.session(sess.id).unwrap().ended_at.is_none(),
1765            "resume keeps the session open"
1766        );
1767        assert_eq!(s.task(id).unwrap().state, TaskState::Running);
1768    }
1769
1770    #[test]
1771    fn rejecting_a_review_keeps_the_same_session_open_with_its_ref() {
1772        // Rejecting with feedback returns the task to running with its session
1773        // still open, so the operator addresses the feedback in that same agent
1774        // session — the ref survives until the task actually closes (DESIGN.md §8).
1775        let (mut s, p) = store_with_project();
1776        let id = create(&mut s, p, TaskState::Ready);
1777        let sess = s
1778            .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1779            .unwrap()
1780            .1;
1781        s.set_session_ref(sess.id, "ref-1").unwrap();
1782        s.apply(id, Action::Complete(None)).unwrap();
1783        s.apply(id, Action::RejectWork("redo the tests".into()))
1784            .unwrap();
1785
1786        let live = s.session(sess.id).unwrap();
1787        assert!(live.ended_at.is_none(), "reject leaves the session open");
1788        assert_eq!(live.session_ref.as_deref(), Some("ref-1"));
1789        assert_eq!(s.task(id).unwrap().state, TaskState::Running);
1790    }
1791
1792    // --- waiting (DESIGN.md §6/§8): work handed off to an external party ---
1793
1794    mod waiting {
1795        use super::*;
1796
1797        #[test]
1798        fn hand_off_keeps_the_session_open_for_a_later_reject() {
1799            // review → waiting must keep the session open exactly as review
1800            // does, so a reject-with-feedback returns to the same agent session
1801            // once the work becomes the operator's move again.
1802            let (mut s, p) = store_with_project();
1803            let id = create(&mut s, p, TaskState::Ready);
1804            let sess = s
1805                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1806                .unwrap()
1807                .1;
1808            s.apply(id, Action::Complete(None)).unwrap();
1809
1810            let task = s.apply(id, Action::HandOff).unwrap();
1811            assert_eq!(task.state, TaskState::Waiting);
1812            assert!(
1813                s.session(sess.id).unwrap().ended_at.is_none(),
1814                "waiting keeps the session open"
1815            );
1816        }
1817
1818        #[test]
1819        fn hand_off_is_refused_from_states_other_than_review() {
1820            // Only review → waiting for now (DESIGN.md §6): a running task is
1821            // not yet handed off, and every other state is nonsensical.
1822            let (mut s, p) = store_with_project();
1823            for state in [
1824                TaskState::Proposed,
1825                TaskState::Parked,
1826                TaskState::Ready,
1827                TaskState::Running,
1828                TaskState::NeedsInput,
1829                TaskState::Stalled,
1830            ] {
1831                let id = task_in_state(&mut s, p, state);
1832                assert!(
1833                    matches!(
1834                        s.apply(id, Action::HandOff),
1835                        Err(Error::InvalidTransition { .. })
1836                    ),
1837                    "hand off should be refused from {state}"
1838                );
1839            }
1840        }
1841
1842        #[test]
1843        fn accept_from_waiting_closes_the_session_completed() {
1844            // The PR merged: accept closes the session `completed`, exactly as
1845            // it does straight from review (DESIGN.md §8).
1846            let (mut s, p) = store_with_project();
1847            let id = create(&mut s, p, TaskState::Ready);
1848            let sess = s
1849                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1850                .unwrap()
1851                .1;
1852            s.apply(id, Action::Complete(None)).unwrap();
1853            s.apply(id, Action::HandOff).unwrap();
1854
1855            let task = s.apply(id, Action::Accept).unwrap();
1856            assert_eq!(task.state, TaskState::Done);
1857            assert!(task.closed_at.is_some());
1858            let closed = s.session(sess.id).unwrap();
1859            assert!(closed.ended_at.is_some());
1860            assert_eq!(closed.outcome, Some(SessionOutcome::Completed));
1861        }
1862
1863        #[test]
1864        fn abandon_from_waiting_closes_the_session_aborted() {
1865            let (mut s, p) = store_with_project();
1866            let id = create(&mut s, p, TaskState::Ready);
1867            let sess = s
1868                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1869                .unwrap()
1870                .1;
1871            s.apply(id, Action::Complete(None)).unwrap();
1872            s.apply(id, Action::HandOff).unwrap();
1873
1874            let task = s.apply(id, Action::Abandon).unwrap();
1875            assert_eq!(task.state, TaskState::Rejected);
1876            assert_eq!(
1877                s.session(sess.id).unwrap().outcome,
1878                Some(SessionOutcome::Aborted)
1879            );
1880        }
1881
1882        #[test]
1883        fn reclaim_pulls_the_work_back_to_review_keeping_the_session() {
1884            let (mut s, p) = store_with_project();
1885            let id = create(&mut s, p, TaskState::Ready);
1886            let sess = s
1887                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1888                .unwrap()
1889                .1;
1890            s.apply(id, Action::Complete(None)).unwrap();
1891            s.apply(id, Action::HandOff).unwrap();
1892
1893            let task = s.apply(id, Action::Reclaim).unwrap();
1894            assert_eq!(task.state, TaskState::Review);
1895            assert!(s.session(sess.id).unwrap().ended_at.is_none());
1896        }
1897
1898        #[test]
1899        fn reject_from_waiting_reuses_the_same_session_with_feedback() {
1900            // The acceptance path: review → wait → reject-with-feedback returns
1901            // the task to running with its original session still open, and the
1902            // feedback recorded, so the operator addresses it in that same
1903            // agent session.
1904            let (mut s, p) = store_with_project();
1905            let id = create(&mut s, p, TaskState::Ready);
1906            let sess = s
1907                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1908                .unwrap()
1909                .1;
1910            s.set_session_ref(sess.id, "ref-1").unwrap();
1911            s.apply(id, Action::Complete(None)).unwrap();
1912            s.apply(id, Action::HandOff).unwrap();
1913
1914            let task = s
1915                .apply(id, Action::RejectWork("reviewer wants tests".into()))
1916                .unwrap();
1917            assert_eq!(task.state, TaskState::Running);
1918            assert!(task.body.contains("## Feedback"));
1919            assert!(task.body.contains("- reviewer wants tests"));
1920
1921            let live = s.session(sess.id).unwrap();
1922            assert!(live.ended_at.is_none(), "reject keeps the session open");
1923            assert_eq!(live.session_ref.as_deref(), Some("ref-1"));
1924        }
1925
1926        #[test]
1927        fn reconcile_leaves_a_waiting_session_untouched() {
1928            // Like review, a waiting task's session stays open regardless of
1929            // process liveness — nothing to reconcile (DESIGN.md §8).
1930            let (mut s, p) = store_with_project();
1931            let id = create(&mut s, p, TaskState::Ready);
1932            let sess = s
1933                .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1934                .unwrap()
1935                .1;
1936            s.apply(id, Action::Complete(None)).unwrap();
1937            s.apply(id, Action::HandOff).unwrap();
1938
1939            assert!(
1940                s.reconcile_session(sess.id, false, false)
1941                    .unwrap()
1942                    .is_none()
1943            );
1944            assert!(s.session(sess.id).unwrap().ended_at.is_none());
1945            assert_eq!(s.task(id).unwrap().state, TaskState::Waiting);
1946        }
1947    }
1948
1949    #[test]
1950    fn a_second_open_session_violates_the_unique_index() {
1951        // The schema backstop: even a raw insert bypassing insert_session's
1952        // supersede cannot leave two open rows on one task.
1953        let (mut s, p) = store_with_project();
1954        let id = create(&mut s, p, TaskState::Ready);
1955        s.record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
1956            .unwrap();
1957        let second = s.conn.execute(
1958            "INSERT INTO sessions (task_id, agent, started_at) VALUES (?1, 'x', datetime('now'))",
1959            [id],
1960        );
1961        assert!(second.is_err(), "a second open session must be rejected");
1962    }
1963
1964    #[test]
1965    fn terminal_states_are_final() {
1966        let (mut s, p) = store_with_project();
1967        for state in [TaskState::Done, TaskState::Rejected] {
1968            let id = task_in_state(&mut s, p, state);
1969            for action in all_actions() {
1970                assert!(s.apply(id, action).is_err(), "{state} must be terminal");
1971            }
1972        }
1973    }
1974
1975    // --- reconcile_session (DESIGN.md §8, the observation half of dispatch) ---
1976
1977    mod reconcile {
1978        use super::*;
1979        use crate::model::SessionOutcome;
1980
1981        fn dispatch(s: &mut Store, p: i64) -> (i64, i64) {
1982            let task_id = create(s, p, TaskState::Ready);
1983            let (_, session) = s
1984                .record_dispatch(
1985                    task_id,
1986                    "claude",
1987                    Some(4242),
1988                    LivenessSource::Pid,
1989                    Some("/var/log/s.log"),
1990                )
1991                .unwrap();
1992            (task_id, session.id)
1993        }
1994
1995        #[test]
1996        fn live_pid_is_left_untouched() {
1997            let (mut s, p) = store_with_project();
1998            let (task_id, session_id) = dispatch(&mut s, p);
1999
2000            let result = s.reconcile_session(session_id, true, false).unwrap();
2001            assert!(result.is_none());
2002            assert_eq!(s.task(task_id).unwrap().state, TaskState::Running);
2003            assert!(s.session(session_id).unwrap().ended_at.is_none());
2004        }
2005
2006        #[test]
2007        fn dead_pid_on_a_running_task_stalls_it() {
2008            let (mut s, p) = store_with_project();
2009            let (task_id, session_id) = dispatch(&mut s, p);
2010
2011            let (session, task) = s
2012                .reconcile_session(session_id, false, false)
2013                .unwrap()
2014                .unwrap();
2015            assert_eq!(session.outcome, Some(SessionOutcome::Failed));
2016            assert!(session.ended_at.is_some());
2017            // the dispatch died under the task: running -> stalled (DESIGN.md
2018            // §6/§8), the attention state that puts redispatch in the queue.
2019            assert_eq!(task.state, TaskState::Stalled);
2020
2021            let events = s.events_for(task_id).unwrap();
2022            let transition = events.last().unwrap();
2023            assert_eq!(transition.kind, "transition");
2024            assert_eq!(
2025                transition.detail.as_deref(),
2026                Some("running -> stalled"),
2027                "{events:?}"
2028            );
2029            let reconcile = &events[events.len() - 2];
2030            assert_eq!(reconcile.kind, "reconcile");
2031            let detail = reconcile.detail.as_deref().unwrap();
2032            assert!(detail.contains("without reporting"), "{detail}");
2033            assert!(detail.contains("failed"), "{detail}");
2034        }
2035
2036        /// A refine round's agent dying is the same probe with a different
2037        /// landing (DESIGN.md §6): the round concludes `failed` and the task
2038        /// returns to `proposed` carrying the failed-round marker, so the
2039        /// operator reads the *old* body knowing the rewrite never happened.
2040        #[test]
2041        fn dead_pid_on_a_refining_task_returns_it_to_proposed() {
2042            let (mut s, p) = store_with_project();
2043            let task_id = create(&mut s, p, TaskState::Proposed);
2044            let (_, session) = s
2045                .record_refine_launch(
2046                    task_id,
2047                    "thin body",
2048                    "claude",
2049                    Some(4242),
2050                    LivenessSource::Pid,
2051                    None,
2052                )
2053                .unwrap();
2054
2055            // a live agent is left alone: the rewrite may still land
2056            assert!(
2057                s.reconcile_session(session.id, true, false)
2058                    .unwrap()
2059                    .is_none()
2060            );
2061            assert_eq!(s.task(task_id).unwrap().state, TaskState::Refining);
2062
2063            let (session, task) = s
2064                .reconcile_session(session.id, false, false)
2065                .unwrap()
2066                .unwrap();
2067            assert_eq!(session.outcome, Some(SessionOutcome::Failed));
2068            assert!(session.ended_at.is_some());
2069            assert_eq!(task.state, TaskState::Proposed);
2070            assert!(s.refine_failed_flag(task_id).unwrap());
2071            assert!(!s.refined_flag(task_id).unwrap());
2072
2073            let events = s.events_for(task_id).unwrap();
2074            assert!(
2075                events.iter().any(|e| e.kind == "reconcile"
2076                    && e.detail.as_deref().is_some_and(|d| d.contains("failed"))),
2077                "{events:?}"
2078            );
2079            assert!(
2080                events
2081                    .iter()
2082                    .any(|e| e.detail.as_deref() == Some("refining -> proposed")),
2083                "{events:?}"
2084            );
2085        }
2086
2087        #[test]
2088        fn dead_pid_reports_capped_when_the_caller_says_so() {
2089            let (mut s, p) = store_with_project();
2090            let (_, session_id) = dispatch(&mut s, p);
2091
2092            let (session, task) = s
2093                .reconcile_session(session_id, false, true)
2094                .unwrap()
2095                .unwrap();
2096            assert_eq!(session.outcome, Some(SessionOutcome::Capped));
2097            // a cap stalls the task the same way a failure does; redispatch
2098            // happens from `stalled` once quota resets (DESIGN.md §6/§8).
2099            assert_eq!(task.state, TaskState::Stalled);
2100        }
2101
2102        #[test]
2103        fn a_stalled_task_can_be_redispatched() {
2104            // record_dispatch's precondition accepts stalled -> running:
2105            // redispatch is the whole point of the state (DESIGN.md §8).
2106            let (mut s, p) = store_with_project();
2107            let (task_id, session_id) = dispatch(&mut s, p);
2108            s.reconcile_session(session_id, false, false).unwrap();
2109            assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
2110
2111            let (task, session) = s
2112                .record_dispatch(
2113                    task_id,
2114                    "codex",
2115                    Some(4343),
2116                    LivenessSource::Pid,
2117                    Some("/var/log/s2.log"),
2118                )
2119                .unwrap();
2120            assert_eq!(task.state, TaskState::Running);
2121            assert_eq!(session.agent, "codex");
2122            assert!(session.ended_at.is_none());
2123        }
2124
2125        #[test]
2126        fn a_stalled_task_completes_to_review_on_the_dead_sessions_behalf() {
2127            // The misfire case (DESIGN.md §8): the session finished but its
2128            // `done` never landed, so reconcile stalled the task. Completion
2129            // from `stalled` reaches review directly — no session is reopened
2130            // and the dead session keeps its recorded outcome.
2131            let (mut s, p) = store_with_project();
2132            let (task_id, session_id) = dispatch(&mut s, p);
2133            s.reconcile_session(session_id, false, false).unwrap();
2134            assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
2135
2136            let task = s
2137                .apply(task_id, Action::Complete(Some("landed on feat/x".into())))
2138                .unwrap();
2139            assert_eq!(task.state, TaskState::Review);
2140
2141            let session = s.session(session_id).unwrap();
2142            assert!(session.ended_at.is_some());
2143            assert_eq!(session.outcome, Some(SessionOutcome::Failed));
2144            assert!(
2145                s.sessions_for(task_id)
2146                    .unwrap()
2147                    .iter()
2148                    .all(|x| x.ended_at.is_some()),
2149                "completing a stall must not open a session"
2150            );
2151
2152            let events = s.events_for(task_id).unwrap();
2153            assert!(
2154                events
2155                    .iter()
2156                    .any(|e| e.kind == "transition"
2157                        && e.detail.as_deref() == Some("stalled -> review")),
2158                "{events:?}"
2159            );
2160            assert!(
2161                events
2162                    .iter()
2163                    .any(|e| e.kind == "summary"
2164                        && e.detail.as_deref() == Some("landed on feat/x")),
2165                "{events:?}"
2166            );
2167        }
2168
2169        #[test]
2170        fn a_stalled_task_with_an_open_blocker_is_parked() {
2171            // Readiness reconciliation treats stalled like ready (DESIGN.md
2172            // §6): a blocker opened mid-run means the stall lands in parked,
2173            // never surfacing unactionable work in the queue.
2174            let (mut s, p) = store_with_project();
2175            let (task_id, session_id) = dispatch(&mut s, p);
2176            let blocker = create(&mut s, p, TaskState::Ready);
2177            s.add_dep(task_id, blocker, crate::model::DepKind::Blocks)
2178                .unwrap();
2179
2180            let (_, task) = s
2181                .reconcile_session(session_id, false, false)
2182                .unwrap()
2183                .unwrap();
2184            assert_eq!(task.state, TaskState::Parked);
2185        }
2186
2187        #[test]
2188        fn adding_an_open_blocker_demotes_a_stalled_task() {
2189            let (mut s, p) = store_with_project();
2190            let (task_id, session_id) = dispatch(&mut s, p);
2191            s.reconcile_session(session_id, false, false).unwrap();
2192            assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled);
2193
2194            let blocker = create(&mut s, p, TaskState::Ready);
2195            s.add_dep(task_id, blocker, crate::model::DepKind::Blocks)
2196                .unwrap();
2197            assert_eq!(s.task(task_id).unwrap().state, TaskState::Parked);
2198
2199            // when the blocker closes it re-promotes to ready, not stalled —
2200            // the stall context is stale by then (DESIGN.md §6).
2201            s.apply(blocker, Action::Start).unwrap();
2202            s.apply(blocker, Action::Complete(None)).unwrap();
2203            s.apply(blocker, Action::Accept).unwrap();
2204            assert_eq!(s.task(task_id).unwrap().state, TaskState::Ready);
2205        }
2206
2207        #[test]
2208        fn a_needs_input_tasks_session_stays_open() {
2209            // The asking session is reused when the answer continues the work,
2210            // so it stays open across needs-input; reconcile leaves it alone
2211            // even with a dead process (DESIGN.md §8).
2212            let (mut s, p) = store_with_project();
2213            let (task_id, session_id) = dispatch(&mut s, p);
2214            s.apply(task_id, Action::Ask("A or B?".into())).unwrap();
2215
2216            assert!(
2217                s.reconcile_session(session_id, false, false)
2218                    .unwrap()
2219                    .is_none()
2220            );
2221            assert!(s.session(session_id).unwrap().ended_at.is_none());
2222            assert_eq!(s.task(task_id).unwrap().state, TaskState::NeedsInput);
2223        }
2224
2225        #[test]
2226        fn a_review_tasks_session_stays_open() {
2227            // Review keeps the session open on purpose, so a reject-with-feedback
2228            // can continue the same agent session; reconcile must not close it.
2229            let (mut s, p) = store_with_project();
2230            let (task_id, session_id) = dispatch(&mut s, p);
2231            s.apply(task_id, Action::Complete(None)).unwrap();
2232
2233            assert!(
2234                s.reconcile_session(session_id, false, false)
2235                    .unwrap()
2236                    .is_none()
2237            );
2238            assert!(s.session(session_id).unwrap().ended_at.is_none());
2239            assert_eq!(s.task(task_id).unwrap().state, TaskState::Review);
2240        }
2241
2242        #[test]
2243        fn aborting_closes_the_session_so_reconcile_is_a_noop() {
2244            // Abort closes the session itself, in the same transaction, so
2245            // reconcile finds nothing left to do.
2246            let (mut s, p) = store_with_project();
2247            let (task_id, session_id) = dispatch(&mut s, p);
2248            s.apply(task_id, Action::Abort).unwrap();
2249
2250            let session = s.session(session_id).unwrap();
2251            assert_eq!(session.outcome, Some(SessionOutcome::Aborted));
2252            assert!(session.ended_at.is_some());
2253            assert!(
2254                s.reconcile_session(session_id, false, false)
2255                    .unwrap()
2256                    .is_none()
2257            );
2258            // a manual abort lands in plain ready, never stalled — the human
2259            // chose to stop the work; nothing about the dispatch died.
2260            assert_eq!(s.task(task_id).unwrap().state, TaskState::Ready);
2261        }
2262
2263        /// The stop seam (DESIGN.md §8): each closing verdict hands back the
2264        /// session it retired — exactly one, already closed, carrying the
2265        /// reference the agent knows it by — so the shell can fire the agent's
2266        /// `stop` verb at it.
2267        #[test]
2268        fn the_closing_verdicts_hand_back_the_session_they_retired() {
2269            for (action, setup) in [
2270                (Action::Abort, Vec::new()),
2271                (Action::Accept, vec![Action::Complete(None)]),
2272                (Action::Abandon, vec![Action::Complete(None)]),
2273            ] {
2274                let (mut s, p) = store_with_project();
2275                let (task_id, session_id) = dispatch(&mut s, p);
2276                s.set_session_ref(session_id, "full-uuid-1").unwrap();
2277                for step in setup {
2278                    s.apply(task_id, step).unwrap();
2279                }
2280
2281                let (_, stopped) = s.apply_closing(task_id, action.clone()).unwrap();
2282                let stopped = stopped.unwrap_or_else(|| panic!("{action:?} retires its session"));
2283                assert_eq!(stopped.id, session_id, "{action:?}");
2284                assert_eq!(stopped.session_ref.as_deref(), Some("full-uuid-1"));
2285                assert!(stopped.ended_at.is_some(), "{action:?}");
2286            }
2287        }
2288
2289        /// The other side of the same rule: a transition that leaves the session
2290        /// open — or closes it as the agent's own report rather than a verdict
2291        /// on it — retires nothing, so the operator's answer, feedback or
2292        /// hand-off still reaches a session that is there to receive it.
2293        #[test]
2294        fn a_transition_that_keeps_its_session_retires_nothing() {
2295            for action in [
2296                Action::Ask("A or B?".into()),
2297                Action::Complete(None),
2298                Action::Park,
2299            ] {
2300                let (mut s, p) = store_with_project();
2301                let (task_id, session_id) = dispatch(&mut s, p);
2302                if action == Action::Park {
2303                    // park is only legal from stalled here, which needs the
2304                    // session finalised first
2305                    s.reconcile_session(session_id, false, false).unwrap();
2306                }
2307                let (_, stopped) = s.apply_closing(task_id, action.clone()).unwrap();
2308                assert!(stopped.is_none(), "{action:?}");
2309            }
2310        }
2311
2312        /// A refine round concludes on the rewriting agent's own `set
2313        /// --body-file`, from inside the session and mid-turn, so its close is
2314        /// not a verdict and retires nothing.
2315        #[test]
2316        fn concluding_a_refine_retires_nothing() {
2317            let (mut s, p) = store_with_project();
2318            let task_id = create(&mut s, p, TaskState::Proposed);
2319            s.record_refine_launch(
2320                task_id,
2321                "thin",
2322                "claude",
2323                Some(1),
2324                LivenessSource::Pid,
2325                None,
2326            )
2327            .unwrap();
2328
2329            let (_, stopped) = s
2330                .apply_closing(
2331                    task_id,
2332                    Action::ConcludeRefine(crate::model::RefineOutcome::Applied),
2333                )
2334                .unwrap();
2335            assert!(stopped.is_none());
2336        }
2337
2338        /// A verdict on a task that never had an agent on it has nothing to
2339        /// retire, so nothing is spawned for it.
2340        #[test]
2341        fn a_task_with_no_open_session_retires_nothing() {
2342            let (mut s, p) = store_with_project();
2343            let task_id = create(&mut s, p, TaskState::Ready);
2344            let (task, stopped) = s.apply_closing(task_id, Action::Abandon).unwrap();
2345            assert_eq!(task.state, TaskState::Rejected);
2346            assert!(stopped.is_none());
2347        }
2348
2349        #[test]
2350        fn a_stale_open_session_on_a_closed_task_is_finalised() {
2351            // A `done` task still carrying an open session (a stranded row):
2352            // reconcile finalises it on the next pass. Forcing the state
2353            // directly reproduces the row `Accept` would otherwise have closed.
2354            let (mut s, p) = store_with_project();
2355            let (task_id, session_id) = dispatch(&mut s, p);
2356            s.conn
2357                .execute(
2358                    "UPDATE tasks SET state = 'done', closed_at = datetime('now') WHERE id = ?1",
2359                    [task_id],
2360                )
2361                .unwrap();
2362
2363            let (session, task) = s
2364                .reconcile_session(session_id, false, false)
2365                .unwrap()
2366                .unwrap();
2367            assert!(session.ended_at.is_some());
2368            assert_eq!(session.outcome, Some(SessionOutcome::Completed));
2369            assert_eq!(task.state, TaskState::Done);
2370        }
2371
2372        #[test]
2373        fn an_already_ended_session_is_not_reprocessed() {
2374            let (mut s, p) = store_with_project();
2375            let (_, session_id) = dispatch(&mut s, p);
2376            s.reconcile_session(session_id, false, false).unwrap();
2377            let first_ended_at = s.session(session_id).unwrap().ended_at;
2378
2379            let result = s.reconcile_session(session_id, false, false).unwrap();
2380            assert!(result.is_none());
2381            assert_eq!(s.session(session_id).unwrap().ended_at, first_ended_at);
2382        }
2383
2384        #[test]
2385        fn redispatch_supersedes_the_prior_session_keeping_one_open() {
2386            // dispatch, abort, redispatch: abort closes the first session, so
2387            // the redispatch opens the only remaining open row. The
2388            // one-open-session invariant holds.
2389            let (mut s, p) = store_with_project();
2390            let (task_id, older_session) = dispatch(&mut s, p);
2391            s.apply(task_id, Action::Abort).unwrap();
2392            assert!(s.session(older_session).unwrap().ended_at.is_some());
2393
2394            let newer_session = s
2395                .record_dispatch(
2396                    task_id,
2397                    "claude",
2398                    Some(4343),
2399                    LivenessSource::Pid,
2400                    Some("/var/log/s2.log"),
2401                )
2402                .unwrap()
2403                .1
2404                .id;
2405
2406            // exactly one open session — the newer one
2407            let open: Vec<i64> = s
2408                .sessions_for(task_id)
2409                .unwrap()
2410                .into_iter()
2411                .filter(|x| x.ended_at.is_none())
2412                .map(|x| x.id)
2413                .collect();
2414            assert_eq!(open, vec![newer_session]);
2415
2416            // reconciling the old, already-closed session does nothing
2417            assert!(
2418                s.reconcile_session(older_session, false, false)
2419                    .unwrap()
2420                    .is_none()
2421            );
2422            assert_eq!(s.task(task_id).unwrap().state, TaskState::Running);
2423            assert!(s.session(newer_session).unwrap().ended_at.is_none());
2424        }
2425
2426        #[test]
2427        fn unknown_session_is_an_error() {
2428            let (mut s, _p) = store_with_project();
2429            assert!(matches!(
2430                s.reconcile_session(9999, false, false),
2431                Err(Error::SessionNotFound(9999))
2432            ));
2433        }
2434    }
2435
2436    #[test]
2437    fn add_dep_rejects_self_blocks() {
2438        let (mut s, p) = store_with_project();
2439        let task = create(&mut s, p, TaskState::Ready);
2440        let err = s.add_dep(task, task, DepKind::Blocks).unwrap_err();
2441        assert!(
2442            matches!(&err, Error::DependencyCycle(path) if path == &format!("{task} -> {task}")),
2443            "expected a self-cycle error, got {err}"
2444        );
2445    }
2446
2447    #[test]
2448    fn add_dep_rejects_direct_cycle() {
2449        let (mut s, p) = store_with_project();
2450        let a = create(&mut s, p, TaskState::Ready);
2451        let b = create(&mut s, p, TaskState::Ready);
2452        s.add_dep(b, a, DepKind::Blocks).unwrap();
2453
2454        let err = s.add_dep(a, b, DepKind::Blocks).unwrap_err();
2455        assert!(
2456            matches!(&err, Error::DependencyCycle(path) if path == &format!("{a} -> {b} -> {a}")),
2457            "expected a direct cycle error, got {err}"
2458        );
2459        // the rejected write must not have landed
2460        assert!(s.deps_of(a).unwrap().is_empty());
2461    }
2462
2463    #[test]
2464    fn add_dep_rejects_transitive_cycle() {
2465        let (mut s, p) = store_with_project();
2466        let a = create(&mut s, p, TaskState::Ready);
2467        let b = create(&mut s, p, TaskState::Ready);
2468        let c = create(&mut s, p, TaskState::Ready);
2469        s.add_dep(b, c, DepKind::Blocks).unwrap();
2470        s.add_dep(c, a, DepKind::Blocks).unwrap();
2471
2472        let err = s.add_dep(a, b, DepKind::Blocks).unwrap_err();
2473        assert!(
2474            matches!(&err, Error::DependencyCycle(path)
2475                if path == &format!("{a} -> {b} -> {c} -> {a}")),
2476            "expected a transitive cycle error, got {err}"
2477        );
2478    }
2479
2480    #[test]
2481    fn add_dep_allows_a_diamond() {
2482        // a depends on b and c; b and c both depend on d. Not a cycle.
2483        let (mut s, p) = store_with_project();
2484        let a = create(&mut s, p, TaskState::Ready);
2485        let b = create(&mut s, p, TaskState::Ready);
2486        let c = create(&mut s, p, TaskState::Ready);
2487        let d = create(&mut s, p, TaskState::Ready);
2488        s.add_dep(b, d, DepKind::Blocks).unwrap();
2489        s.add_dep(c, d, DepKind::Blocks).unwrap();
2490        s.add_dep(a, b, DepKind::Blocks).unwrap();
2491        s.add_dep(a, c, DepKind::Blocks).unwrap();
2492
2493        let deps = s.deps_of(a).unwrap();
2494        assert_eq!(
2495            deps.iter().map(|d| d.depends_on).collect::<Vec<_>>(),
2496            vec![b, c]
2497        );
2498    }
2499
2500    #[test]
2501    fn set_blocks_deps_rejects_self_and_transitive_cycles() {
2502        let (mut s, p) = store_with_project();
2503        let a = create(&mut s, p, TaskState::Ready);
2504        let b = create(&mut s, p, TaskState::Ready);
2505        let c = create(&mut s, p, TaskState::Ready);
2506
2507        let err = s.set_blocks_deps(a, &[a]).unwrap_err();
2508        assert!(matches!(&err, Error::DependencyCycle(path) if path == &format!("{a} -> {a}")));
2509
2510        // b -> c -> a, then a -> b would close the cycle
2511        s.set_blocks_deps(b, &[c]).unwrap();
2512        s.set_blocks_deps(c, &[a]).unwrap();
2513        let err = s.set_blocks_deps(a, &[b]).unwrap_err();
2514        assert!(matches!(&err, Error::DependencyCycle(path)
2515                if path == &format!("{a} -> {b} -> {c} -> {a}")));
2516        // the rejected write must leave the task's existing blocks deps alone
2517        assert!(s.deps_of(a).unwrap().is_empty());
2518    }
2519
2520    #[test]
2521    fn set_blocks_deps_allows_a_diamond() {
2522        let (mut s, p) = store_with_project();
2523        let a = create(&mut s, p, TaskState::Ready);
2524        let b = create(&mut s, p, TaskState::Ready);
2525        let c = create(&mut s, p, TaskState::Ready);
2526        let d = create(&mut s, p, TaskState::Ready);
2527        s.set_blocks_deps(b, &[d]).unwrap();
2528        s.set_blocks_deps(c, &[d]).unwrap();
2529
2530        let t = s.set_blocks_deps(a, &[b, c]).unwrap();
2531        assert_eq!(t.state, TaskState::Parked);
2532    }
2533}