Skip to main content

marver/
domain.rs

1//! Core domain types. The task state machine lives here;
2//! [`TaskState::can_transition_to`] is the executable copy of `ARCHITECTURE.md`
3//! §4.
4
5use std::fmt;
6use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Where a task is in its lifecycle.
12///
13/// ```text
14/// queued ──▶ running ⟷ blocked ──▶ awaiting-review ──▶ committed
15///               ▲                          │
16///               └────────── reject ────────┘
17///
18/// queued | running | blocked ⟷ paused
19/// any non-terminal state ──▶ cancelled
20/// queued | running | blocked | paused ──▶ failed
21/// ```
22///
23/// Where a paused task returns to is not stored — it is whether the task has a
24/// tmux session. See [`TaskState::Paused`].
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum TaskState {
28    /// Created, waiting for a concurrency slot.
29    Queued,
30    /// Agent is working.
31    Running,
32    /// Agent needs the user. Entered via the Claude Code `Notification` hook.
33    Blocked,
34    /// Agent finished; the diff needs review. Entered via the `Stop` hook.
35    AwaitingReview,
36    /// Set aside by the user. Holds no concurrency slot.
37    Paused,
38    /// Changes committed locally. Terminal.
39    Committed,
40    /// The agent crashed, the session died, or setup never completed.
41    /// Terminal.
42    Failed,
43    /// Abandoned by the user. Terminal.
44    Cancelled,
45}
46
47impl TaskState {
48    /// Every state, in lifecycle order. Useful for filters and exhaustive
49    /// tests.
50    pub const ALL: &'static [TaskState] = &[
51        Self::Queued,
52        Self::Running,
53        Self::Blocked,
54        Self::AwaitingReview,
55        Self::Paused,
56        Self::Committed,
57        Self::Failed,
58        Self::Cancelled,
59    ];
60
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Queued => "queued",
64            Self::Running => "running",
65            Self::Blocked => "blocked",
66            Self::AwaitingReview => "awaiting-review",
67            Self::Paused => "paused",
68            Self::Committed => "committed",
69            Self::Failed => "failed",
70            Self::Cancelled => "cancelled",
71        }
72    }
73
74    pub fn parse(s: &str) -> Option<Self> {
75        Some(match s {
76            "queued" => Self::Queued,
77            "running" => Self::Running,
78            "blocked" => Self::Blocked,
79            "awaiting-review" => Self::AwaitingReview,
80            "paused" => Self::Paused,
81            "committed" => Self::Committed,
82            "failed" => Self::Failed,
83            "cancelled" => Self::Cancelled,
84            _ => return None,
85        })
86    }
87
88    /// Every state a task may legally move to from here.
89    pub fn allowed_next(self) -> &'static [TaskState] {
90        match self {
91            Self::Queued => &[Self::Running, Self::Paused, Self::Failed, Self::Cancelled],
92            // Blocked and finished are both reachable while running.
93            Self::Running => &[
94                Self::Blocked,
95                Self::AwaitingReview,
96                Self::Paused,
97                Self::Failed,
98                Self::Cancelled,
99            ],
100            // Unblocking may return to running, but it may also go straight to
101            // review: Claude Code emits no hook when the user answers a
102            // permission prompt, so the next thing marver hears from a blocked
103            // agent is often the `Stop` that says it finished.
104            Self::Blocked => &[
105                Self::Running,
106                Self::AwaitingReview,
107                Self::Paused,
108                Self::Failed,
109                Self::Cancelled,
110            ],
111            // Accept commits; reject resumes the same live session.
112            Self::AwaitingReview => &[Self::Committed, Self::Running, Self::Cancelled],
113            // Back where it came from, told apart by whether a session exists.
114            Self::Paused => &[Self::Queued, Self::Running, Self::Failed, Self::Cancelled],
115            Self::Committed => &[],
116            Self::Failed => &[],
117            Self::Cancelled => &[],
118        }
119    }
120
121    pub fn can_transition_to(self, next: TaskState) -> bool {
122        self.allowed_next().contains(&next)
123    }
124
125    /// Terminal states have no outgoing transitions.
126    pub fn is_terminal(self) -> bool {
127        self.allowed_next().is_empty()
128    }
129}
130
131impl fmt::Display for TaskState {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// Why a task is blocked.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum BlockedKind {
141    /// Waiting on a permission prompt (`permission_prompt`).
142    PermissionPrompt,
143    /// Asked the user something (`elicitation_dialog`, `agent_needs_input`).
144    Question,
145    /// Went idle waiting for input (`idle_prompt`).
146    Silence,
147}
148
149impl BlockedKind {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Self::PermissionPrompt => "permission-prompt",
153            Self::Question => "question",
154            Self::Silence => "silence",
155        }
156    }
157
158    pub fn parse(s: &str) -> Option<Self> {
159        Some(match s {
160            "permission-prompt" => Self::PermissionPrompt,
161            "question" => Self::Question,
162            "silence" => Self::Silence,
163            _ => return None,
164        })
165    }
166}
167
168impl fmt::Display for BlockedKind {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.write_str(self.as_str())
171    }
172}
173
174/// A git repository discovered under the scan root.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Repo {
177    pub id: i64,
178    /// Absolute path to the repository working directory.
179    pub path: PathBuf,
180    /// Directory name, used for display and worktree naming.
181    pub name: String,
182    /// Hidden from repo pickers without being forgotten.
183    pub ignored: bool,
184    pub discovered_at: DateTime<Utc>,
185    /// Updated on every scan that still finds it; lets us spot vanished repos.
186    pub last_seen_at: DateTime<Utc>,
187}
188
189/// The unit of work: one agent, one session, one workspace directory.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Task {
192    pub id: i64,
193    pub title: String,
194    /// What the agent was asked to do.
195    pub prompt: String,
196    pub state: TaskState,
197    /// Set only while `state` is [`TaskState::Blocked`].
198    pub blocked_kind: Option<BlockedKind>,
199    pub blocked_reason: Option<String>,
200    /// Set only while `state` is [`TaskState::Failed`].
201    pub failure_reason: Option<String>,
202    /// Parent directory holding this task's worktrees; the session's cwd.
203    pub workspace_dir: PathBuf,
204    /// tmux session name, once one exists.
205    pub session_name: Option<String>,
206    pub created_at: DateTime<Utc>,
207    pub updated_at: DateTime<Utc>,
208    /// What the agent has spent, as far as its transcript says.
209    pub usage: TaskUsage,
210    /// When it was put out of the way, if it has been.
211    pub archived_at: Option<DateTime<Utc>>,
212}
213
214impl Task {
215    pub fn is_archived(&self) -> bool {
216        self.archived_at.is_some()
217    }
218}
219
220/// What a task's agent has spent.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct TaskUsage {
223    /// Model named by the most recent assistant turn, verbatim.
224    pub model: Option<String>,
225    /// How full the context window was on that turn. A level, not a total.
226    pub context_tokens: Option<u64>,
227    /// Output tokens across every turn so far. A total, not a level.
228    pub output_tokens: Option<u64>,
229    /// Where the transcript has been read up to.
230    pub transcript_offset: u64,
231    /// Where the transcript is, as the hooks reported it.
232    pub transcript_path: Option<PathBuf>,
233}
234
235impl TaskUsage {
236    /// Whether there is anything worth showing.
237    pub fn is_known(&self) -> bool {
238        self.model.is_some() || self.context_tokens.is_some() || self.output_tokens.is_some()
239    }
240}
241
242/// A repo a task targets, and the worktree made for it once provisioned.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct TaskRepo {
245    pub task_id: i64,
246    pub repo_id: i64,
247    /// Absolute path to the worktree, once it exists.
248    pub worktree_path: Option<PathBuf>,
249    /// Branch created for this task in this repo, once provisioned.
250    pub branch: Option<String>,
251    /// What `branch` was cut from, as resolved at provision time.
252    pub base_ref: Option<String>,
253}
254
255impl TaskRepo {
256    /// Whether a worktree exists on disk for this pairing.
257    pub fn is_provisioned(&self) -> bool {
258        self.worktree_path.is_some()
259    }
260}
261
262/// Something to do, at one of two scopes.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct Todo {
265    pub id: i64,
266    /// `None` for a global todo.
267    pub task_id: Option<i64>,
268    pub text: String,
269    pub done: bool,
270    pub created_at: DateTime<Utc>,
271}
272
273impl Todo {
274    pub fn is_global(&self) -> bool {
275        self.task_id.is_none()
276    }
277}
278
279/// Which todos to read or write.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum TodoScope {
282    /// Not yet scoped to any task.
283    Global,
284    /// Belonging to one task.
285    Task(i64),
286}
287
288impl TodoScope {
289    pub fn task_id(self) -> Option<i64> {
290        match self {
291            Self::Global => None,
292            Self::Task(id) => Some(id),
293        }
294    }
295}
296
297/// An append-only record of something that happened.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct Event {
300    pub id: i64,
301    /// `None` for events not tied to a task, such as a repo scan.
302    pub task_id: Option<i64>,
303    /// Dotted identifier, e.g. `task.transition` or `hook.notification`.
304    pub kind: String,
305    /// Arbitrary JSON payload.
306    pub payload: serde_json::Value,
307    pub created_at: DateTime<Utc>,
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn state_strings_round_trip() {
316        for &state in TaskState::ALL {
317            assert_eq!(TaskState::parse(state.as_str()), Some(state));
318        }
319        assert_eq!(TaskState::parse("nonsense"), None);
320    }
321
322    #[test]
323    fn blocked_kind_strings_round_trip() {
324        for kind in [
325            BlockedKind::PermissionPrompt,
326            BlockedKind::Question,
327            BlockedKind::Silence,
328        ] {
329            assert_eq!(BlockedKind::parse(kind.as_str()), Some(kind));
330        }
331        assert_eq!(BlockedKind::parse("nonsense"), None);
332    }
333
334    #[test]
335    fn happy_path_is_walkable() {
336        let path = [
337            TaskState::Queued,
338            TaskState::Running,
339            TaskState::AwaitingReview,
340            TaskState::Committed,
341        ];
342        for pair in path.windows(2) {
343            assert!(
344                pair[0].can_transition_to(pair[1]),
345                "{} should reach {}",
346                pair[0],
347                pair[1]
348            );
349        }
350    }
351
352    #[test]
353    fn blocking_round_trips_through_running() {
354        assert!(TaskState::Running.can_transition_to(TaskState::Blocked));
355        assert!(TaskState::Blocked.can_transition_to(TaskState::Running));
356    }
357
358    #[test]
359    fn a_blocked_agent_can_finish_without_being_seen_to_resume() {
360        // Answering a permission prompt emits no hook, so the next thing
361        // marver hears is the `Stop` that means "done".
362        assert!(TaskState::Blocked.can_transition_to(TaskState::AwaitingReview));
363    }
364
365    #[test]
366    fn rejection_resumes_the_same_session() {
367        assert!(TaskState::AwaitingReview.can_transition_to(TaskState::Running));
368    }
369
370    #[test]
371    fn only_the_three_end_states_are_terminal() {
372        let terminal = [
373            TaskState::Committed,
374            TaskState::Failed,
375            TaskState::Cancelled,
376        ];
377        for &state in TaskState::ALL {
378            assert_eq!(
379                state.is_terminal(),
380                terminal.contains(&state),
381                "{state} has the wrong terminality"
382            );
383        }
384    }
385
386    #[test]
387    fn anything_unfinished_can_be_cancelled() {
388        for &state in TaskState::ALL {
389            if state.is_terminal() {
390                continue;
391            }
392            assert!(
393                state.can_transition_to(TaskState::Cancelled),
394                "{state} should be cancellable"
395            );
396        }
397    }
398
399    #[test]
400    fn failure_is_reachable_only_while_work_is_outstanding() {
401        for &state in &[TaskState::Queued, TaskState::Running, TaskState::Blocked] {
402            assert!(
403                state.can_transition_to(TaskState::Failed),
404                "{state} should be able to fail"
405            );
406        }
407        // The agent has already finished by this point; there is nothing left
408        // to crash.
409        assert!(!TaskState::AwaitingReview.can_transition_to(TaskState::Failed));
410    }
411
412    #[test]
413    fn terminal_states_never_resume() {
414        for &state in TaskState::ALL {
415            if !state.is_terminal() {
416                continue;
417            }
418            for &next in TaskState::ALL {
419                assert!(
420                    !state.can_transition_to(next),
421                    "{state} should not reach {next}"
422                );
423            }
424        }
425    }
426
427    #[test]
428    fn queued_cannot_skip_running() {
429        assert!(!TaskState::Queued.can_transition_to(TaskState::AwaitingReview));
430        assert!(!TaskState::Queued.can_transition_to(TaskState::Committed));
431        assert!(!TaskState::Queued.can_transition_to(TaskState::Blocked));
432    }
433
434    #[test]
435    fn no_state_transitions_to_itself() {
436        for &state in TaskState::ALL {
437            assert!(!state.can_transition_to(state), "{state} loops on itself");
438        }
439    }
440}