Skip to main content

mermaid_cli/providers/
tasks.rs

1//! Single-writer broker for the task checklist.
2//!
3//! Owns the authoritative [`TaskStore`]. The three task tools, `/tasks` user
4//! edits (`Cmd::UserTaskEdit`), evidence recording, and the `task_completed`
5//! veto path all mutate through here; every mutation publishes a full
6//! snapshot as `Msg::TasksUpdated`, which the reducer copies onto
7//! `conversation.tasks` for render + persistence. Serializing every writer
8//! through one lock is what lets a `/tasks add` land safely while a turn's
9//! tool call is mid-flight.
10//!
11//! Unlike `QuestionBroker` there is no parking: every operation is
12//! fire-and-forget, tools never block on the user.
13//!
14//! Lock discipline matches the other brokers: [`std::sync::Mutex`] (guard is
15//! `!Send`, so holding it across an `.await` fails to compile); mutate, clone
16//! the snapshot, drop the guard, then publish.
17//!
18//! Cost stamps: the broker reads the wall clock (impure side — fine) and the
19//! latest token reading pushed by the effect runner via [`note_tokens`].
20//! Stamps flow into the domain as plain data on the snapshot, so `--replay`
21//! reproduces them from the recorded `Msg` instead of recomputing.
22//!
23//! [`note_tokens`]: TaskBroker::note_tokens
24
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex};
27
28use tokio::sync::mpsc;
29
30use crate::domain::Msg;
31use crate::domain::tasks::{
32    ApplyReport, EvidenceEntry, Stamp, TaskEdit, TaskItem, TaskOrigin, TaskSpec, TaskStatus,
33    TaskStore, UserTaskEdit,
34};
35
36#[derive(Clone)]
37pub struct TaskBroker {
38    store: Arc<Mutex<TaskStore>>,
39    /// Session-monotonic completion-token counter, accumulated by the effect
40    /// runner as providers report usage. Task cost deltas are computed
41    /// between the readings at in_progress and completed.
42    tokens: Arc<AtomicU64>,
43    msg_tx: mpsc::Sender<Msg>,
44}
45
46impl TaskBroker {
47    pub fn new(msg_tx: mpsc::Sender<Msg>) -> Self {
48        Self {
49            store: Arc::new(Mutex::new(TaskStore::default())),
50            tokens: Arc::new(AtomicU64::new(0)),
51            msg_tx,
52        }
53    }
54
55    /// Overwrite the store wholesale: startup resume seeding, rewind/fork
56    /// and `/clear` (empty store). No publish — the reducer already holds
57    /// this truth; it is telling us, not the other way around.
58    pub fn seed(&self, store: TaskStore) {
59        *self.lock() = store;
60    }
61
62    /// Accumulate a completed request's completion tokens into the
63    /// session-monotonic counter. Called by the effect runner on every
64    /// provider usage report; task cost deltas read this counter at the
65    /// in_progress and completed edges.
66    pub fn add_tokens(&self, completion_tokens: u64) {
67        self.tokens.fetch_add(completion_tokens, Ordering::Relaxed);
68    }
69
70    /// Append new tasks; returns the created items and publishes.
71    pub async fn create(
72        &self,
73        specs: Vec<TaskSpec>,
74        origin: TaskOrigin,
75    ) -> (Vec<TaskItem>, TaskStore) {
76        let (created, snapshot) = {
77            let mut store = self.lock();
78            let ids = store.create(specs, origin, self.stamp());
79            let created = store
80                .tasks
81                .iter()
82                .filter(|t| ids.contains(&t.id))
83                .cloned()
84                .collect();
85            (created, store.clone())
86        };
87        self.publish(snapshot.clone()).await;
88        (created, snapshot)
89    }
90
91    /// Apply differential edits; returns the per-item report (with advisory
92    /// notes) and publishes.
93    pub async fn update(&self, edits: Vec<TaskEdit>) -> (ApplyReport, TaskStore) {
94        let (report, snapshot) = {
95            let mut store = self.lock();
96            let report = store.apply(&edits, self.stamp());
97            (report, store.clone())
98        };
99        self.publish(snapshot.clone()).await;
100        (report, snapshot)
101    }
102
103    /// Apply a `/tasks` user edit. Returns the outcome line shown in the
104    /// transcript and the id it affected (for the model notice).
105    pub async fn user_edit(&self, edit: UserTaskEdit) -> (String, TaskStore) {
106        let (line, snapshot) = {
107            let mut store = self.lock();
108            let subject_of = |store: &TaskStore, id: u32| {
109                store
110                    .tasks
111                    .iter()
112                    .find(|t| t.id == id)
113                    .map(|t| t.subject.clone())
114                    .unwrap_or_default()
115            };
116            let line = match edit {
117                UserTaskEdit::Add { subject } => {
118                    let ids = store.create(
119                        vec![TaskSpec {
120                            active_form: subject.clone(),
121                            subject: subject.clone(),
122                            description: None,
123                            in_progress: false,
124                        }],
125                        TaskOrigin::User,
126                        self.stamp(),
127                    );
128                    format!("Added task #{} '{subject}'", ids[0])
129                },
130                UserTaskEdit::Remove { id } => {
131                    let subject = subject_of(&store, id);
132                    let report = store.apply(
133                        &[TaskEdit {
134                            id,
135                            status: Some(TaskStatus::Deleted),
136                            ..TaskEdit::default()
137                        }],
138                        self.stamp(),
139                    );
140                    match report.errors.first() {
141                        Some(err) => err.clone(),
142                        None => format!("Removed task #{id} '{subject}'"),
143                    }
144                },
145                UserTaskEdit::Done { id } => {
146                    let subject = subject_of(&store, id);
147                    let report = store.apply(
148                        &[TaskEdit {
149                            id,
150                            status: Some(TaskStatus::Completed),
151                            ..TaskEdit::default()
152                        }],
153                        self.stamp(),
154                    );
155                    match report.errors.first() {
156                        Some(err) => err.clone(),
157                        None => format!("Marked task #{id} '{subject}' completed"),
158                    }
159                },
160                UserTaskEdit::Clear => {
161                    *store = TaskStore::default();
162                    "Cleared the task list".to_string()
163                },
164            };
165            (line, store.clone())
166        };
167        self.publish(snapshot.clone()).await;
168        (line, snapshot)
169    }
170
171    /// Attach evidence to the current in-progress task, if any. Publishes
172    /// only when something was recorded.
173    pub async fn record_evidence(&self, entry: EvidenceEntry) {
174        let snapshot = {
175            let mut store = self.lock();
176            store.record_evidence(entry).then(|| store.clone())
177        };
178        if let Some(snapshot) = snapshot {
179            self.publish(snapshot).await;
180        }
181    }
182
183    pub fn snapshot(&self) -> TaskStore {
184        self.lock().clone()
185    }
186
187    fn stamp(&self) -> Stamp {
188        Stamp {
189            now_epoch: std::time::SystemTime::now()
190                .duration_since(std::time::UNIX_EPOCH)
191                .map(|d| d.as_secs())
192                .unwrap_or(0),
193            run_tokens: self.tokens.load(Ordering::Relaxed),
194        }
195    }
196
197    fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> {
198        self.store
199            .lock()
200            .unwrap_or_else(|poisoned| poisoned.into_inner())
201    }
202
203    /// Fire-and-forget snapshot to the reducer. A closed channel (shutdown)
204    /// is ignored — the broker's copy is still truth for any later reader.
205    async fn publish(&self, store: TaskStore) {
206        let _ = self.msg_tx.send(Msg::TasksUpdated { store }).await;
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn spec(subject: &str, in_progress: bool) -> TaskSpec {
215        TaskSpec {
216            subject: subject.into(),
217            active_form: format!("{subject}ing"),
218            description: None,
219            in_progress,
220        }
221    }
222
223    async fn recv_store(rx: &mut mpsc::Receiver<Msg>) -> TaskStore {
224        match rx.recv().await {
225            Some(Msg::TasksUpdated { store }) => store,
226            other => panic!("expected TasksUpdated, got {other:?}"),
227        }
228    }
229
230    #[tokio::test]
231    async fn create_and_update_publish_snapshots() {
232        let (tx, mut rx) = mpsc::channel(8);
233        let broker = TaskBroker::new(tx);
234        let (created, _) = broker
235            .create(vec![spec("a", true), spec("b", false)], TaskOrigin::Model)
236            .await;
237        assert_eq!(created.len(), 2);
238        assert_eq!(recv_store(&mut rx).await.counts(), (0, 2));
239
240        let (report, _) = broker
241            .update(vec![TaskEdit {
242                id: created[0].id,
243                status: Some(TaskStatus::Completed),
244                ..TaskEdit::default()
245            }])
246            .await;
247        assert!(report.errors.is_empty());
248        let published = recv_store(&mut rx).await;
249        assert_eq!(published.counts(), (1, 2));
250    }
251
252    #[tokio::test]
253    async fn token_readings_feed_cost_stamps() {
254        let (tx, _rx) = mpsc::channel(8);
255        let broker = TaskBroker::new(tx);
256        broker.add_tokens(1_000);
257        let (created, _) = broker
258            .create(vec![spec("a", true)], TaskOrigin::Model)
259            .await;
260        broker.add_tokens(8_400);
261        let (_, snapshot) = broker
262            .update(vec![TaskEdit {
263                id: created[0].id,
264                status: Some(TaskStatus::Completed),
265                ..TaskEdit::default()
266            }])
267            .await;
268        assert_eq!(snapshot.tasks[0].tokens_spent, Some(8_400));
269    }
270
271    #[tokio::test]
272    async fn seed_overwrites_without_publishing() {
273        let (tx, mut rx) = mpsc::channel(8);
274        let broker = TaskBroker::new(tx);
275        let mut store = TaskStore::default();
276        store.create(
277            vec![spec("seeded", false)],
278            TaskOrigin::Model,
279            Stamp::default(),
280        );
281        broker.seed(store);
282        assert_eq!(broker.snapshot().tasks.len(), 1);
283        assert!(rx.try_recv().is_err(), "seed must not publish");
284    }
285
286    #[tokio::test]
287    async fn user_edits_apply_and_report() {
288        let (tx, mut rx) = mpsc::channel(8);
289        let broker = TaskBroker::new(tx);
290        let (line, _) = broker
291            .user_edit(UserTaskEdit::Add {
292                subject: "review the docs".into(),
293            })
294            .await;
295        assert_eq!(line, "Added task #1 'review the docs'");
296        assert_eq!(recv_store(&mut rx).await.tasks[0].origin, TaskOrigin::User);
297
298        let (line, snapshot) = broker.user_edit(UserTaskEdit::Remove { id: 9 }).await;
299        assert_eq!(line, "#9: no such task");
300        assert_eq!(snapshot.visible().count(), 1);
301    }
302
303    #[tokio::test]
304    async fn evidence_publishes_only_when_recorded() {
305        let (tx, mut rx) = mpsc::channel(8);
306        let broker = TaskBroker::new(tx);
307        // No in-progress task yet: nothing recorded, nothing published.
308        broker
309            .record_evidence(EvidenceEntry {
310                tool: "edit_file".into(),
311                target: "a.rs".into(),
312                status: "ok".into(),
313            })
314            .await;
315        assert!(rx.try_recv().is_err());
316
317        broker
318            .create(vec![spec("a", true)], TaskOrigin::Model)
319            .await;
320        let _ = recv_store(&mut rx).await;
321        broker
322            .record_evidence(EvidenceEntry {
323                tool: "edit_file".into(),
324                target: "a.rs".into(),
325                status: "ok".into(),
326            })
327            .await;
328        let published = recv_store(&mut rx).await;
329        assert_eq!(published.tasks[0].evidence.len(), 1);
330    }
331}