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}