Skip to main content

mermaid_cli/providers/
tasks.rs

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