Skip to main content

basis_tasks/
tasks.rs

1//! The workspace's tasks, read straight off its directories.
2//!
3//! ADR-0019 made the filesystem the coordination surface, and this is the verb
4//! that says so out loud: `basis list` takes no lock and derives every state
5//! from the two facts an executor already publishes — whether `terminal.json`
6//! exists, and whether anyone holds `attach.lock`. It is the same derivation
7//! `wait` and `watch` use, reached through the same
8//! [`probe_state`], so the three verbs cannot
9//! disagree about what a task is doing.
10//!
11//! It mints nothing. A workspace that has never run anything is reported as
12//! having no tasks, through [`DataDir::described_workspace`] rather than
13//! `ensure_workspace`, because a listing that created the directory proving
14//! its own answer wrong is not an observation.
15//!
16//! The scan answers a second question too. "The conversation I was just
17//! having" is the first row here that has one, which is what
18//! `spawn --continue` resolves against — one implementation, so a handle
19//! `list` printed is a handle `--session` accepts.
20//!
21//! Which makes the row order load-bearing, and it is the order of last
22//! activity, not of birth: a task started this morning and worked in a minute
23//! ago is the conversation you are in, and the one spawned after lunch and
24//! abandoned is not. The age each row prints is that same activity, so the
25//! listing is ordered by a fact it shows — `--json` keeps `started_ms` for
26//! anyone who wanted the birthday. See [`last_activity_ms`] for what counts.
27
28use std::path::Path;
29
30use basis::RunUsage;
31use serde_json::{Value, json};
32
33use crate::{
34    data_dir::{AgentPaths, DataDir, canonical_workspace, valid_task_handle, workspace_key},
35    inbox, lock,
36    state::{MessageRecord, TaskMeta, load_meta, read_terminal},
37};
38
39/// How much of a prompt's first line a row carries. A row is an index entry,
40/// not the prompt — `basis watch <ID>` has the whole run.
41const PROMPT_BUDGET: usize = 64;
42
43/// One task, as [`Tasks::list`](crate::Tasks::list) reports it and as a
44/// `--continue`-shaped continuation picks from it.
45#[derive(Debug, Clone)]
46pub struct TaskSummary {
47    pub task: String,
48    /// `running`, `resumable`, or whatever the terminal record settled on.
49    pub state: String,
50    pub started_ms: u64,
51    /// When this task was last worked in, per this crate's own
52    /// `last_activity_ms`. What the list is ordered by, and what a
53    /// continuation picks by.
54    pub last_activity_ms: u64,
55    /// The prompt's first line, bounded by `PROMPT_BUDGET`.
56    pub prompt: String,
57    /// The mentra conversation this task minted or continued. Empty until a
58    /// first attach prepares one, which is why a never-attached task cannot be
59    /// continued: there is nothing yet to continue.
60    pub agent_id: String,
61    pub usage: RunUsage,
62}
63
64impl TaskSummary {
65    /// The `basis list --json` row shape (ADR-0015): the fields above, plus
66    /// `continuable` and, when the task spent anything, `usage`. Kept here
67    /// rather than duplicated by a caller building its own object, so a
68    /// script reading `--json` and a host reading this struct's JSON agree by
69    /// construction.
70    pub fn payload(&self) -> Value {
71        let mut payload = json!({
72            "task": self.task,
73            "state": self.state,
74            "started_ms": self.started_ms,
75            "last_activity_ms": self.last_activity_ms,
76            "prompt": self.prompt,
77            "continuable": !self.agent_id.is_empty(),
78        });
79        // Absent rather than zeroed, the same rule the terminal record and the
80        // finish line follow: nothing reported is not a measurement of nothing.
81        if self.usage != RunUsage::default() {
82            payload["usage"] = json!(self.usage);
83        }
84        payload
85    }
86}
87
88/// Every task recorded for `workspace`, last worked in first, or `None` when
89/// nothing has ever run there.
90///
91/// The digest is checked against the path it claims to describe before a
92/// single row is read: two workspaces sharing an FNV key would otherwise list
93/// each other's tasks, and a handle copied out of that list would name work
94/// somewhere else entirely.
95pub(crate) fn workspace_tasks(
96    data: &DataDir,
97    workspace: &Path,
98) -> Result<Option<Vec<TaskSummary>>, String> {
99    let canonical = canonical_workspace(workspace)
100        .map_err(|error| format!("resolve workspace {}: {error}", workspace.display()))?;
101    let key = workspace_key(&canonical);
102    match data.described_workspace(&key) {
103        None => return Ok(None),
104        Some(described) if described != canonical => {
105            return Err(format!(
106                "workspace key collision: {key} describes {}, not {}",
107                described.display(),
108                canonical.display()
109            ));
110        }
111        Some(_) => {}
112    }
113    Ok(Some(scan(data, &key)?))
114}
115
116/// The agent directories under one workspace key, last worked in first.
117///
118/// A directory whose metadata cannot be read is skipped rather than fatal: a
119/// half-written agent dir is what a `kill -9` during `spawn` leaves, and one
120/// unreadable neighbour must not cost a person the list of everything else.
121fn scan(data: &DataDir, key: &str) -> Result<Vec<TaskSummary>, String> {
122    let agents = data.agents_dir(key);
123    let entries = match std::fs::read_dir(&agents) {
124        Ok(entries) => entries,
125        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
126        Err(error) => return Err(format!("scan workspace agents: {error}")),
127    };
128
129    let mut summaries = Vec::new();
130    for entry in entries {
131        let entry = entry.map_err(|error| format!("scan workspace agents: {error}"))?;
132        let task = format!("{key}/{}", entry.file_name().to_string_lossy());
133        let Some(paths) = data.agent_dir(&task).filter(AgentPaths::exists) else {
134            continue;
135        };
136        let Ok(meta) = load_meta(&paths) else {
137            continue;
138        };
139        // An unreadable inbox costs this row its messages, not its place: the
140        // executor's own clock is still a complete-enough answer, and the same
141        // rule the unreadable-neighbour skip above follows.
142        let messages = inbox::load(&paths).unwrap_or_default();
143        // And an unreadable terminal record costs this row its state, not the
144        // list its rows: `task_state` already answers "unknown" for a terminal
145        // whose `state` field is not a string, and a record that does not
146        // parse is the same fact one level down. Only in the survey — `wait`
147        // and `watch` on the damaged task itself still fail loudly, because
148        // asking about one task is a different question from listing them all.
149        let state = task_state(&paths).unwrap_or_else(|_| "unknown".to_string());
150        summaries.push(TaskSummary {
151            state,
152            started_ms: meta.created_ms,
153            last_activity_ms: last_activity_ms(&meta, &messages),
154            prompt: first_line(&meta.prompt),
155            agent_id: meta.agent_id,
156            usage: meta.usage,
157            task,
158        });
159    }
160    // Last worked in first, ties broken by handle so two tasks touched in the
161    // same millisecond still list in a stable order rather than the
162    // directory's.
163    summaries.sort_by(|left, right| {
164        right
165            .last_activity_ms
166            .cmp(&left.last_activity_ms)
167            .then_with(|| left.task.cmp(&right.task))
168    });
169    Ok(summaries)
170}
171
172/// When anything last happened in a task, read off the two files that already
173/// record it: `meta.json`, which the executor rewrites as it attaches, banks a
174/// turn, and settles, and `inbox.json`, where a sender stamps the message it
175/// enqueued.
176///
177/// Derived rather than stored, so neither writer has to reach into the other's
178/// file. `send` runs under the inbox lock while an executor may be holding the
179/// attach lock, and a second writer on `meta.json` is a lost update waiting to
180/// happen — the banked usage of a turn that finished between that sender's
181/// read and its write. The two clocks already exist; the maximum of them is
182/// the fact, and nothing new has to be kept consistent (ADR-0019).
183///
184/// **Reading is not activity.** `watch`, `list`, and `wait` on a settled task
185/// write nothing here, and must not: looking at a run is not being in the
186/// conversation, and a `watch` left open in another terminal would otherwise
187/// decide what `--continue` picks up. `wait` on an *unsettled* task does
188/// count — it attaches and runs turns, which is the executor working rather
189/// than a reader looking.
190///
191/// Never earlier than the start. A record that carries no activity — one
192/// written before basis recorded any — resolves by `created_ms` rather than by
193/// zero, which would sort every task predating this field behind every task
194/// after it.
195fn last_activity_ms(meta: &TaskMeta, messages: &[MessageRecord]) -> u64 {
196    let sent = messages
197        .iter()
198        .map(|message| message.created_ms)
199        .max()
200        .unwrap_or_default();
201    meta.created_ms.max(meta.updated_ms).max(sent)
202}
203
204/// A task's state, derived exactly as `wait` and `watch` derive it: the
205/// terminal record first, because it is immutable and repeatably observable,
206/// then the attach lock, which is the only evidence a live executor leaves.
207fn task_state(paths: &AgentPaths) -> Result<String, String> {
208    match read_terminal(paths)? {
209        Some(terminal) => Ok(terminal["state"].as_str().unwrap_or("unknown").to_string()),
210        None => Ok(probe_state(lock::is_held(&paths.attach_lock())).to_string()),
211    }
212}
213
214/// The task's honest state while unfinished: `running` only when a live
215/// executor observably holds the attach lock, `resumable` otherwise. The same
216/// two-fact derivation [`Tasks::wait`](crate::Tasks::wait) and
217/// [`Tasks::watch`](crate::Tasks::watch) settle a timeout's `attached` field
218/// with, so all three answer "what is this task doing" the same way.
219pub fn probe_state(attached: bool) -> &'static str {
220    if attached { "running" } else { "resumable" }
221}
222
223/// The conversation `--continue` picks up: the task in this workspace last
224/// worked in that has one.
225///
226/// The first row, because `scan` has already put the summaries in that order —
227/// which is the whole reason the order is what it is. Picking by start time
228/// answers a different question, and answers it wrong the moment two
229/// conversations are open at once: the one you replied to is the one you are
230/// in, whatever the birthdays say.
231///
232/// Tasks that never minted an agent are skipped rather than refused. A
233/// `--resumable` spawn nobody attached to is a perfectly ordinary thing to
234/// have lying around, and "continue what I was doing" plainly does not mean
235/// it: there is no conversation there to continue.
236pub(crate) fn latest_conversation(summaries: &[TaskSummary]) -> Option<&TaskSummary> {
237    summaries
238        .iter()
239        .find(|summary| !summary.agent_id.is_empty())
240}
241
242/// Why [`named`] refused a handle — the distinction its caller needs to map
243/// onto its own vocabulary ([`Error::invalid_reference`](crate::Error::invalid_reference)
244/// versus an ordinary failure, in `Tasks`; `ClientError::usage` versus
245/// `ClientError::new` in `basis-cli`).
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub(crate) enum NamedError {
248    /// Malformed grammar, or a handle from another workspace — an argument
249    /// that was never going to resolve here, whatever else settles.
250    InvalidReference(String),
251    /// Well-formed, this workspace, but no task recorded under it — a state
252    /// fact (the directory is gone, or was never there), not a bad argument.
253    NotFound(String),
254}
255
256/// The task a handle names, when it names one in this workspace.
257///
258/// A handle from another workspace is refused rather than searched for: the
259/// key is half the handle, and a task's conversation belongs to the workspace
260/// whose context and tools it ran with.
261pub(crate) fn named<'a>(
262    summaries: &'a [TaskSummary],
263    workspace_key: &str,
264    handle: &str,
265) -> Result<&'a TaskSummary, NamedError> {
266    let Some((key, _)) = valid_task_handle(handle) else {
267        return Err(NamedError::InvalidReference(format!(
268            "`{handle}` is not a task handle"
269        )));
270    };
271    if key != workspace_key {
272        return Err(NamedError::InvalidReference(format!(
273            "task {handle} belongs to another workspace; list it where it was started"
274        )));
275    }
276    summaries
277        .iter()
278        .find(|summary| summary.task == handle)
279        .ok_or_else(|| NamedError::NotFound(format!("no task directory for {handle}")))
280}
281
282/// A non-terminal task in this workspace that already names `agent_id` as
283/// what it continues — the claim [`Tasks::spawn`](crate::Tasks::spawn) checks
284/// before minting a second one under the same [`continue_lock`](DataDir),
285/// and the reason a claim can be checked for at all: two tasks racing to
286/// continue one conversation would both eventually call `Workspace::resume`
287/// on it, and only refusing the second claimant here — before either ever
288/// attaches — keeps that from being the first anyone hears of it.
289///
290/// Scanned directly rather than through [`workspace_tasks`], which answers a
291/// different question (`--continue`'s own target) and does not carry
292/// `continues` on its rows.
293pub(crate) fn claimed_continuation(
294    data: &DataDir,
295    key: &str,
296    agent_id: &str,
297) -> Result<Option<String>, String> {
298    let agents = data.agents_dir(key);
299    let entries = match std::fs::read_dir(&agents) {
300        Ok(entries) => entries,
301        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
302        Err(error) => return Err(format!("scan workspace agents: {error}")),
303    };
304    for entry in entries {
305        let entry = entry.map_err(|error| format!("scan workspace agents: {error}"))?;
306        let task = format!("{key}/{}", entry.file_name().to_string_lossy());
307        let Some(paths) = data.agent_dir(&task).filter(AgentPaths::exists) else {
308            continue;
309        };
310        let Ok(meta) = load_meta(&paths) else {
311            continue;
312        };
313        if meta.continues.as_deref() != Some(agent_id) {
314            continue;
315        }
316        if read_terminal(&paths)?.is_some() {
317            // Settled — its claim released the moment it did, the same rule
318            // `latest_conversation` skipping a never-attached task follows a
319            // level up: a claim only means anything while it is open.
320            continue;
321        }
322        return Ok(Some(task));
323    }
324    Ok(None)
325}
326
327/// The prompt's first line, bounded.
328fn first_line(prompt: &str) -> String {
329    let line = prompt.lines().next().unwrap_or_default().trim();
330    match line.char_indices().nth(PROMPT_BUDGET) {
331        Some((end, _)) => format!("{}…", &line[..end]),
332        None => line.to_string(),
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::state::{MessageState, RunOptions, save_meta, write_terminal};
340
341    /// A row that has been touched exactly once, when it started — the shape
342    /// every assertion below that is not about activity wants.
343    fn summary(task: &str, state: &str, started_ms: u64, agent_id: &str) -> TaskSummary {
344        TaskSummary {
345            task: task.to_string(),
346            state: state.to_string(),
347            started_ms,
348            last_activity_ms: started_ms,
349            prompt: "fix the failing test".to_string(),
350            agent_id: agent_id.to_string(),
351            usage: RunUsage::default(),
352        }
353    }
354
355    fn meta(created_ms: u64, updated_ms: u64) -> TaskMeta {
356        let mut meta = TaskMeta::new(
357            "w/t".to_string(),
358            None,
359            true,
360            "/repo".to_string(),
361            "fix the failing test".to_string(),
362            RunOptions::default(),
363            None,
364        );
365        meta.created_ms = created_ms;
366        meta.updated_ms = updated_ms;
367        meta
368    }
369
370    fn message(created_ms: u64) -> MessageRecord {
371        MessageRecord {
372            id: format!("m{created_ms}"),
373            body: "more".to_string(),
374            state: MessageState::Pending,
375            created_ms,
376            reply: None,
377        }
378    }
379
380    #[test]
381    fn a_json_row_says_whether_it_can_be_continued() {
382        let started = summary("w/t", "succeeded", 5, "agent-1").payload();
383        assert_eq!(started["task"], "w/t");
384        assert_eq!(started["state"], "succeeded");
385        assert_eq!(started["started_ms"], 5);
386        assert_eq!(started["continuable"], true);
387        assert!(
388            started.get("usage").is_none(),
389            "a task that reported nothing claims no measurement: {started}"
390        );
391
392        let never_attached = summary("w/t2", "resumable", 5, "").payload();
393        assert_eq!(
394            never_attached["continuable"], false,
395            "an agent nobody attached to has no conversation yet"
396        );
397    }
398
399    #[test]
400    fn a_json_row_carries_what_the_task_spent() {
401        let mut spent = summary("w/t", "succeeded", 5, "agent-1");
402        spent.usage = RunUsage {
403            input_tokens: 900,
404            output_tokens: 100,
405            ..RunUsage::default()
406        };
407
408        assert_eq!(spent.payload()["usage"]["input_tokens"], 900);
409    }
410
411    /// `--continue` means "the conversation I was just having", and a
412    /// `--resumable` agent nobody ever attached to is not one.
413    #[test]
414    fn continue_takes_the_first_task_in_the_list_that_has_a_conversation() {
415        let summaries = vec![
416            summary("w/untouched", "resumable", 300, ""),
417            summary("w/middle", "succeeded", 200, "agent-middle"),
418            summary("w/oldest", "succeeded", 100, "agent-oldest"),
419        ];
420
421        assert_eq!(
422            latest_conversation(&summaries).map(|summary| summary.task.as_str()),
423            Some("w/middle")
424        );
425        assert!(
426            latest_conversation(&summaries[..1]).is_none(),
427            "a workspace whose only task never ran has nothing to continue"
428        );
429    }
430
431    /// The rule `--continue` rides on. Both writers count and neither owns the
432    /// answer: the executor stamps `meta.json` as it works, a sender stamps
433    /// the message it enqueued, and the later of the two is when this task was
434    /// last a conversation somebody was in.
435    #[test]
436    fn activity_is_the_latest_thing_either_writer_recorded() {
437        assert_eq!(
438            last_activity_ms(&meta(100, 300), &[]),
439            300,
440            "a task nobody has written to since its last turn"
441        );
442        assert_eq!(
443            last_activity_ms(&meta(100, 300), &[message(200), message(700)]),
444            700,
445            "a message sent after the last turn is the newer fact"
446        );
447        assert_eq!(
448            last_activity_ms(&meta(100, 900), &[message(700)]),
449            900,
450            "and so is a turn run after the last message"
451        );
452        assert_eq!(
453            last_activity_ms(&meta(100, 0), &[]),
454            100,
455            "a record written before basis kept this clock falls back to its start"
456        );
457    }
458
459    /// The listing is ordered by activity, and `--json` carries that same
460    /// clock alongside the birthday rather than only the one it is ordered
461    /// by — a caller wanting either arithmetic gets both.
462    #[test]
463    fn a_json_row_carries_both_clocks() {
464        let mut worked = summary("w/t", "succeeded", 0, "agent-1");
465        worked.last_activity_ms = 7_140_000;
466
467        let payload = worked.payload();
468        assert_eq!(payload["started_ms"], 0, "the birthday survives in --json");
469        assert_eq!(payload["last_activity_ms"], 7_140_000);
470    }
471
472    #[test]
473    fn a_handle_from_another_workspace_is_refused_rather_than_searched_for() {
474        let here = "0123456789abcdef";
475        let elsewhere = format!("fedcba9876543210/{:032x}", 1);
476        let summaries = vec![summary(&format!("{here}/{:032x}", 1), "succeeded", 1, "a")];
477
478        let error = named(&summaries, here, &elsewhere).expect_err("refused");
479        assert!(
480            matches!(error, NamedError::InvalidReference(_)),
481            "{error:?}"
482        );
483        assert!(
484            format!("{error:?}").contains("another workspace"),
485            "{error:?}"
486        );
487
488        let malformed = named(&summaries, here, "not-a-handle").expect_err("refused");
489        assert!(
490            matches!(malformed, NamedError::InvalidReference(_)),
491            "{malformed:?}"
492        );
493        assert!(format!("{malformed:?}").contains("not a task handle"));
494
495        let found = named(&summaries, here, &summaries[0].task).expect("in this workspace");
496        assert_eq!(found.task, summaries[0].task);
497    }
498
499    /// Well-formed and this workspace's key, but no such task is recorded —
500    /// a state fact, distinct from an argument that could never have
501    /// resolved: `resolve_continuation` maps this to an ordinary `Error`
502    /// rather than `invalid_reference`.
503    #[test]
504    fn a_handle_that_fits_the_grammar_but_names_nothing_is_not_found_not_invalid() {
505        let here = "0123456789abcdef";
506        let missing = format!("{here}/{:032x}", 99);
507        let summaries = vec![summary(&format!("{here}/{:032x}", 1), "succeeded", 1, "a")];
508
509        let error = named(&summaries, here, &missing).expect_err("refused");
510        assert!(matches!(error, NamedError::NotFound(_)), "{error:?}");
511        assert!(
512            format!("{error:?}").contains("no task directory"),
513            "{error:?}"
514        );
515    }
516
517    /// T2(a): a task that already records `continues = Some(agent_id)` and
518    /// has not yet settled is an open claim on that conversation — the fact
519    /// `Tasks::spawn` checks before minting a second claimant.
520    #[test]
521    fn a_still_open_continuation_is_a_claim_a_settled_one_releases() {
522        let dir = tempfile::tempdir().unwrap();
523        let data = DataDir::from_path(dir.path()).unwrap();
524        let key = "0123456789abcdef";
525        let claimant = format!("{key}/{:032x}", 1);
526        let paths = data.agent_dir(&claimant).unwrap();
527        std::fs::create_dir_all(paths.dir()).unwrap();
528        let meta = TaskMeta::new(
529            claimant.clone(),
530            None,
531            true,
532            "/repo".to_string(),
533            "continue it".to_string(),
534            RunOptions::default(),
535            None,
536        )
537        .continuing(Some("conversation-1".to_string()));
538        save_meta(&paths, &meta).unwrap();
539
540        assert_eq!(
541            claimed_continuation(&data, key, "conversation-1").unwrap(),
542            Some(claimant.clone()),
543            "an unsettled claimant is still holding its claim"
544        );
545        assert_eq!(
546            claimed_continuation(&data, key, "some-other-conversation").unwrap(),
547            None,
548            "a claim on one conversation says nothing about another"
549        );
550
551        write_terminal(&paths, &json!({"state": "succeeded", "result": "done"})).unwrap();
552        assert_eq!(
553            claimed_continuation(&data, key, "conversation-1").unwrap(),
554            None,
555            "a settled claimant's claim released the moment it settled"
556        );
557    }
558
559    #[test]
560    fn a_row_carries_one_bounded_line_of_the_prompt() {
561        assert_eq!(first_line("fix the test\nthen push"), "fix the test");
562        assert!(first_line(&"x".repeat(200)).ends_with('…'));
563        assert_eq!(
564            first_line(&"界".repeat(100)).chars().count(),
565            PROMPT_BUDGET + 1
566        );
567    }
568}