Skip to main content

basis_tasks/
client.rs

1//! `Tasks`: the crate's front door.
2//!
3//! One workspace's durable tasks, over one global [`DataDir`]. `spawn` mints
4//! a task and returns immediately — the mint is a `mkdir` and an atomic
5//! write, nothing durable ever blocks on a model. Everything that *drives* a
6//! task (`ask`, `wait`, `wait_message`) attaches only when no other process
7//! already holds it, and every other verb (`send`, `cancel`, `watch`,
8//! `list`, `terminal`, ...) only ever reads or writes small files under the
9//! attach lock's protection, never taking it. No verb here leaves a resident
10//! process behind (ADR-0019).
11//!
12//! `workspace` matters to exactly two of these: [`spawn`](Tasks::spawn),
13//! which mints under it, and [`list`](Tasks::list), which scans it. Every
14//! other method takes a [`TaskHandle`] that already names its own workspace
15//! key, and resolves purely from that — a host holding a handle can `wait`
16//! or `cancel` it regardless of which workspace `Tasks::open` was given.
17
18use std::{
19    path::{Path, PathBuf},
20    sync::Arc,
21    time::Duration,
22};
23
24use serde_json::Value;
25
26use crate::{
27    Error, Hint,
28    approve::PromptHost,
29    attach,
30    data_dir::{self, DataDir, canonical_workspace},
31    events::EventTail,
32    handle::TaskHandle,
33    inbox,
34    live::{DriveContext, LiveSink},
35    lock, policy,
36    spec::{Continuation, DEFAULT_DEADLINE, RunSpec},
37    state::{self, InboxRecord, RunOptions, TaskMeta, TerminalRecord},
38    tasks::{self, TaskSummary},
39    watch::EventCursor,
40};
41
42/// The current attach's outcome, or a bounded timeout — see
43/// [`attach::WaitOutcome`], which this re-exports as the return type of
44/// [`Tasks::wait`] and [`Tasks::wait_message`].
45pub use attach::WaitOutcome;
46
47/// What [`Tasks::ask`] enqueued and what came of waiting for it.
48///
49/// `message_id` is the durable correlation handle — the same id [`Tasks::send`]
50/// would have returned for the enqueue alone — carried back so a caller can
51/// retry through [`Tasks::wait_message`] if the wait itself times out, or
52/// report it in an "accepted" record either way.
53#[derive(Debug, Clone, PartialEq)]
54pub struct Reply {
55    pub message_id: String,
56    pub outcome: WaitOutcome,
57}
58
59/// One workspace's durable tasks.
60///
61/// `Clone` because a handful of this type's own `async fn`s need an owned
62/// copy to move onto a blocking thread (see `blocking`, below) — every field
63/// is already cheap to clone (a path, an `Arc`), so this costs nothing a
64/// caller could not already do by hand.
65#[derive(Clone)]
66pub struct Tasks {
67    data: DataDir,
68    workspace: PathBuf,
69    prompt_host: Option<Arc<dyn PromptHost>>,
70}
71
72impl Tasks {
73    /// Opens the durable task store: `BASIS_DATA_DIR`, else an absolute
74    /// `XDG_DATA_HOME`, else the platform data home (created private on first
75    /// use). `workspace` is read by [`spawn`](Self::spawn) and
76    /// [`list`](Self::list) only; every handle-scoped method resolves
77    /// straight from the handle, so opening `Tasks` is cheap and does not
78    /// itself touch `workspace` on disk.
79    pub fn open(workspace: impl Into<PathBuf>) -> Result<Self, Error> {
80        let data = DataDir::discover()
81            .map_err(|error| Error::new(format!("open task data directory: {error}")))?;
82        Ok(Self {
83            data,
84            workspace: workspace.into(),
85            prompt_host: None,
86        })
87    }
88
89    /// Opens the durable task store at an explicit root, bypassing the
90    /// `BASIS_DATA_DIR`/XDG discovery [`open`](Self::open) performs.
91    ///
92    /// For a host that manages its own data directory location rather than
93    /// the process environment — several `Tasks` in one process, each with
94    /// its own root, for one — and for a test that wants no dependency on
95    /// `std::env` at all.
96    pub fn open_at(
97        data_dir: impl Into<PathBuf>,
98        workspace: impl Into<PathBuf>,
99    ) -> Result<Self, Error> {
100        let data = DataDir::from_path(data_dir.into())
101            .map_err(|error| Error::new(format!("open task data directory: {error}")))?;
102        Ok(Self {
103            data,
104            workspace: workspace.into(),
105            prompt_host: None,
106        })
107    }
108
109    /// Supplies how this `Tasks` answers `Approve::Prompt` while it drives a
110    /// task, and whether it can be asked at all — see [`PromptHost`]. Without
111    /// one, `Prompt` refuses the same way an unaskable process always did;
112    /// `basis-cli` is the first caller of this.
113    #[must_use]
114    pub fn with_prompt_host(self, prompt_host: Arc<dyn PromptHost>) -> Self {
115        Self {
116            prompt_host: Some(prompt_host),
117            ..self
118        }
119    }
120
121    /// Whether this `Tasks` can currently put an `Approve::Prompt` question
122    /// to whoever answers for it — `false` with no [`PromptHost`] supplied.
123    pub fn can_ask(&self) -> bool {
124        self.prompt_host.as_deref().is_some_and(PromptHost::can_ask)
125    }
126
127    /// The mentra store directory `workspace` resolves to, without minting or
128    /// reading any task.
129    ///
130    /// What a host's attended, one-shot route — no durable task, no handle —
131    /// needs to land its conversation store on the same directory
132    /// [`spawn`](Self::spawn) would use for the same workspace, so the two
133    /// share one conversation history and one memory root rather than
134    /// falling back to two.
135    pub fn store_dir(workspace: &Path) -> Result<PathBuf, Error> {
136        let data = DataDir::discover()
137            .map_err(|error| Error::new(format!("open task data directory: {error}")))?;
138        data.resolve_store_dir(workspace).map_err(Error::new)
139    }
140
141    /// A private directory for one host's own records about this workspace's
142    /// tasks — what an adapter keeps that a task directory does not, such as
143    /// which protocol-level ids it handed out for which handles.
144    ///
145    /// `<data root>/workspaces/<key>/hosts/<host>`, created `0700` on first
146    /// use. `host` is one path component of lowercase ASCII letters, digits,
147    /// and `-`, so a name can never reach outside its own directory. Nothing
148    /// in this crate reads it; deleting it loses the host's index, never a
149    /// task.
150    pub fn host_dir(&self, host: &str) -> Result<PathBuf, Error> {
151        self.data
152            .resolve_host_dir(&self.workspace, host)
153            .map_err(Error::new)
154    }
155
156    fn ctx(&self, live: Option<Arc<dyn LiveSink>>) -> DriveContext {
157        DriveContext::new(live, self.prompt_host.clone())
158    }
159
160    /// Mints a task and returns its handle immediately. Durable and resumable
161    /// from the moment this returns: nothing has attached yet, and nothing
162    /// has to for the handle to be good.
163    pub fn spawn(&self, spec: RunSpec) -> Result<TaskHandle, Error> {
164        let RunSpec {
165            prompt,
166            provider,
167            base_url,
168            model,
169            shell,
170            system_prompt,
171            effort,
172            approve,
173            deadline,
174            tool_budget,
175            token_budget,
176            detached,
177            continuation,
178        } = spec;
179        if prompt.trim().is_empty() {
180            return Err(Error::new("prompt is empty"));
181        }
182        if prompt.len() > state::MAX_PROMPT {
183            return Err(Error::new(format!(
184                "prompt is {} bytes; the limit is {}",
185                prompt.len(),
186                state::MAX_PROMPT
187            )));
188        }
189        // Refused here, before a task directory is even minted: a task whose
190        // approval mode can never be honored by this `Tasks` — no
191        // `PromptHost`, or one that cannot currently ask — is refused as a
192        // cheaper, clearer failure than one that could never make progress.
193        crate::approve::validate_approval(approve, self.can_ask())?;
194
195        let deadline_ms = duration_ms(deadline.unwrap_or(DEFAULT_DEADLINE));
196        let options = RunOptions {
197            provider,
198            base_url,
199            model,
200            no_shell: !shell,
201            system_prompt,
202            append_system_prompt: None,
203            effort,
204            approve,
205            deadline_ms: Some(deadline_ms),
206            tool_budget,
207            token_budget,
208        };
209
210        let canonical = canonical_workspace(&self.workspace).map_err(|error| {
211            Error::new(format!(
212                "resolve workspace {}: {error}",
213                self.workspace.display()
214            ))
215        })?;
216        let key = self.data.ensure_workspace(&canonical).map_err(Error::new)?;
217
218        // Read off the published BASIS_TASK_ID protocol: a task spawning
219        // another is, by default, that task's parent (ADR-0017's tree).
220        let caller = crate::current_task();
221        if let Some(caller) = &caller
222            && !detached
223            && caller.key() != key
224        {
225            return Err(Error::new(format!(
226                "current task {caller} belongs to another workspace; spawn a detached task to \
227                 start work here"
228            ))
229            .with_hint(Hint::SpawnDetached));
230        }
231        let parent = if detached { None } else { caller };
232
233        // T2(a): resolving which conversation to continue, minting the task
234        // directory, and recording the claim (`continues`) on it are one
235        // unit against a second spawn targeting this same workspace —
236        // otherwise two concurrent `--continue`s could both resolve the same
237        // conversation before either's claim is on disk for the other to
238        // see. Held past `save_meta` below, then dropped explicitly: nothing
239        // after that point reads or writes what this protects.
240        let continue_lock = lock::exclusive(&self.data.continue_lock(&key))
241            .map_err(|error| Error::new(format!("acquire workspace continuation lock: {error}")))?;
242        let continues = self.resolve_continuation(&canonical, &key, continuation)?;
243
244        let requested_deadline = Some(
245            state::now_ms()
246                .checked_add(deadline_ms)
247                .ok_or_else(|| Error::new("task deadline exceeds the system clock range"))?,
248        );
249        let deadline_at = match &parent {
250            Some(parent_handle) => {
251                let paths = attach::resolve(&self.data, parent_handle.as_str()).map_err(|_| {
252                    Error::new(format!("parent task {parent_handle} does not exist"))
253                })?;
254                let owner = state::load_meta(&paths).map_err(Error::new)?;
255                let accepts = state::read_terminal(&paths).map_err(Error::new)?.is_none()
256                    && owner.pending_terminal.is_none()
257                    && !state::cancel_requested(&paths)
258                    && !owner.deadline_passed();
259                if !accepts {
260                    return Err(Error::new(format!(
261                        "parent task {parent_handle} is no longer running"
262                    )));
263                }
264                attach::earlier_deadline(requested_deadline, owner.deadline_at_ms)
265            }
266            None => requested_deadline,
267        };
268
269        let agents = self.data.agents_dir(&key);
270        let existing = std::fs::read_dir(&agents)
271            .map_err(|error| Error::new(format!("scan workspace agents: {error}")))?
272            .count();
273        if existing >= state::MAX_TASKS {
274            return Err(Error::new(format!(
275                "workspace has {} tasks (the limit); archive old agent directories under {}",
276                state::MAX_TASKS,
277                agents.display()
278            )));
279        }
280
281        // `create_dir` is the atomic claim on the handle; a uuid collision retries.
282        let (task, paths) = loop {
283            let task = format!("{key}/{}", uuid::Uuid::new_v4().simple());
284            let paths = self
285                .data
286                .agent_dir(&task)
287                .expect("a minted handle is well-formed");
288            match std::fs::create_dir(paths.dir()) {
289                Ok(()) => {
290                    data_dir::restrict_directory(paths.dir())
291                        .map_err(|error| Error::new(format!("restrict task directory: {error}")))?;
292                    break (task, paths);
293                }
294                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
295                Err(error) => return Err(Error::new(format!("create task directory: {error}"))),
296            }
297        };
298        let meta = TaskMeta::new(
299            task.clone(),
300            parent.map(|handle| handle.as_str().to_string()),
301            detached,
302            canonical.to_string_lossy().into_owned(),
303            prompt,
304            options,
305            deadline_at,
306        )
307        .continuing(continues);
308        state::save_meta(&paths, &meta).map_err(Error::new)?;
309        drop(continue_lock);
310
311        TaskHandle::parse(task)
312    }
313
314    /// The conversation `continuation` names, when it names one: `New`
315    /// mints, `Latest` picks the conversation this workspace was last worked
316    /// in, `Named` picks a specific one — the same resolution `--continue`
317    /// and `--continue --session <ID>` need.
318    fn resolve_continuation(
319        &self,
320        workspace: &Path,
321        key: &str,
322        continuation: Continuation,
323    ) -> Result<Option<String>, Error> {
324        let requested = match continuation {
325            Continuation::New => return Ok(None),
326            Continuation::Latest => None,
327            Continuation::Named(handle) => Some(handle),
328        };
329        let summaries = tasks::workspace_tasks(&self.data, workspace)
330            .map_err(Error::new)?
331            .unwrap_or_default();
332        let chosen = match &requested {
333            // `named` tells a bad argument (malformed, or another workspace's
334            // handle) apart from a state fact (well-formed, this workspace,
335            // simply not recorded) — the same distinction `Error` carries.
336            Some(handle) => {
337                tasks::named(&summaries, key, handle.as_str()).map_err(|error| match error {
338                    tasks::NamedError::InvalidReference(message) => {
339                        Error::invalid_reference(message)
340                    }
341                    tasks::NamedError::NotFound(message) => Error::new(message),
342                })?
343            }
344            None => tasks::latest_conversation(&summaries).ok_or_else(|| {
345                Error::new("no task in this workspace has a conversation to continue")
346                    .with_hint(Hint::SpawnFresh)
347            })?,
348        };
349        if chosen.state == "running" {
350            return Err(Error::new(format!(
351                "task {} is running; its attach lock is what keeps one conversation to one \
352                 executor",
353                chosen.task
354            ))
355            .with_hint(Hint::Wait(TaskHandle::parse(chosen.task.clone())?)));
356        }
357        if chosen.agent_id.is_empty() {
358            return Err(Error::new(format!(
359                "task {} has no conversation yet: nothing has attached to it",
360                chosen.task
361            ))
362            .with_hint(Hint::Wait(TaskHandle::parse(chosen.task.clone())?)));
363        }
364        // T2(a): a sibling that already recorded `continues` against this
365        // same conversation, and has not yet settled, holds an open claim on
366        // it — minting a second claimant here is exactly the race that lets
367        // two executors resume one conversation at once.
368        if let Some(claimant) =
369            tasks::claimed_continuation(&self.data, key, &chosen.agent_id).map_err(Error::new)?
370        {
371            let claimant = TaskHandle::parse(claimant)?;
372            return Err(Error::new(format!(
373                "task {claimant} already continues this conversation; a conversation admits \
374                 one open claim at a time"
375            ))
376            .with_hint(Hint::Wait(claimant)));
377        }
378        Ok(Some(chosen.agent_id.clone()))
379    }
380
381    /// Enqueues a follow-up turn. Never blocks and never drives: the message
382    /// is durable the moment this returns, and progress happens only once
383    /// something attaches — `ask`, `wait`, or any other attacher.
384    pub fn send(&self, handle: &TaskHandle, message: impl Into<String>) -> Result<String, Error> {
385        let paths = attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
386        inbox::enqueue(&paths, handle.as_str(), message.into()).map_err(Error::new)
387    }
388
389    /// Enqueues a follow-up turn and awaits its correlated reply, attaching
390    /// to drive the task whenever the attach lock is free. `send` plus
391    /// [`wait_message`](Self::wait_message), as one call — the edge is
392    /// validated before the enqueue, so a rejected wait cannot leave a
393    /// message behind.
394    ///
395    /// **Threading:** the edge check and the enqueue are this call's own
396    /// synchronous prelude and run through this crate's own `blocking`
397    /// helper; the wait after it is `attach::wait_for_message`'s own.
398    pub async fn ask(
399        &self,
400        handle: &TaskHandle,
401        caller: Option<&TaskHandle>,
402        message: impl Into<String>,
403        timeout: Duration,
404    ) -> Result<Reply, Error> {
405        let data = self.data.clone();
406        let target = handle.clone();
407        let caller = caller.cloned();
408        let message = message.into();
409        let message_id = blocking(move || {
410            policy::validate_wait_edge(
411                &data,
412                caller.as_ref().map(TaskHandle::as_str),
413                target.as_str(),
414            )
415            .map_err(Error::new)?;
416            let paths = attach::resolve(&data, target.as_str()).map_err(Error::new)?;
417            inbox::enqueue(&paths, target.as_str(), message).map_err(Error::new)
418        })
419        .await?;
420        let outcome = attach::wait_for_message(
421            &self.data,
422            handle.as_str(),
423            &message_id,
424            timeout,
425            self.prompt_host.clone(),
426        )
427        .await
428        .map_err(Error::new)?;
429        Ok(Reply {
430            message_id,
431            outcome,
432        })
433    }
434
435    /// Awaits one already-enqueued message's correlated reply — the `wait
436    /// --message` shape: repeatable without any policy check once the reply
437    /// exists, and edge-validated only for the wait that has to actually
438    /// happen.
439    ///
440    /// **Threading:** the dispatch check and the edge check are this call's
441    /// own synchronous prelude and run through this crate's own `blocking`
442    /// helper; the wait after it, when one is still needed, is
443    /// `attach::wait_for_message`'s own.
444    pub async fn wait_message(
445        &self,
446        handle: &TaskHandle,
447        caller: Option<&TaskHandle>,
448        message_id: &str,
449        timeout: Duration,
450    ) -> Result<WaitOutcome, Error> {
451        let data = self.data.clone();
452        let target = handle.clone();
453        let caller = caller.cloned();
454        let mid = message_id.to_string();
455        let resolved = blocking(move || -> Result<Option<Value>, Error> {
456            let paths = attach::resolve(&data, target.as_str()).map_err(Error::new)?;
457            let messages = inbox::load(&paths).map_err(Error::new)?;
458            let terminal = state::read_terminal(&paths).map_err(Error::new)?;
459            if let Some(payload) = inbox::message_payload_for_dispatch(
460                target.as_str(),
461                &messages,
462                &mid,
463                terminal.as_ref(),
464            )
465            .map_err(Error::new)?
466            {
467                return Ok(Some(payload));
468            }
469            policy::validate_wait_edge(
470                &data,
471                caller.as_ref().map(TaskHandle::as_str),
472                target.as_str(),
473            )
474            .map_err(Error::new)?;
475            Ok(None)
476        })
477        .await?;
478        match resolved {
479            Some(payload) => Ok(WaitOutcome::Terminal(TerminalRecord::from_raw(payload))),
480            None => attach::wait_for_message(
481                &self.data,
482                handle.as_str(),
483                message_id,
484                timeout,
485                self.prompt_host.clone(),
486            )
487            .await
488            .map_err(Error::new),
489        }
490    }
491
492    /// Awaits the task's terminal record, attaching to drive it whenever the
493    /// attach lock is free — repeatable: a settled task's record is read
494    /// straight off disk, never rerun. `live`, when given, is shown every
495    /// event while (and only while) this call is the one driving.
496    ///
497    /// **Threading:** the terminal read and the edge check are this call's
498    /// own synchronous prelude and run through this crate's own `blocking`
499    /// helper; the wait after it, when one is still needed, is this crate's
500    /// own `wait_unvalidated`'s.
501    pub async fn wait(
502        &self,
503        handle: &TaskHandle,
504        caller: Option<&TaskHandle>,
505        timeout: Duration,
506        live: Option<Arc<dyn LiveSink>>,
507    ) -> Result<WaitOutcome, Error> {
508        let data = self.data.clone();
509        let target = handle.clone();
510        let caller = caller.cloned();
511        let resolved = blocking(move || -> Result<Option<Value>, Error> {
512            let paths = attach::resolve(&data, target.as_str()).map_err(Error::new)?;
513            if let Some(terminal) = state::read_terminal(&paths).map_err(Error::new)? {
514                return Ok(Some(terminal));
515            }
516            policy::validate_wait_edge(
517                &data,
518                caller.as_ref().map(TaskHandle::as_str),
519                target.as_str(),
520            )
521            .map_err(Error::new)?;
522            Ok(None)
523        })
524        .await?;
525        match resolved {
526            Some(terminal) => Ok(WaitOutcome::Terminal(TerminalRecord::from_raw(terminal))),
527            None => self.wait_unvalidated(handle, timeout, live).await,
528        }
529    }
530
531    /// [`wait`](Self::wait) minus the edge check — never exposed on its own,
532    /// since any caller could reach it for any handle and there would be
533    /// nothing left of the wait-edge policy. [`spawn_and_wait`](Self::spawn_and_wait)
534    /// is the one legitimate skip: the edge between a call's own caller and
535    /// the task it just minted is exactly the one [`spawn`](Self::spawn)
536    /// established a moment ago (a fresh descendant, or an independent root
537    /// when detached), and re-deriving it through a `caller` a host is free
538    /// to pass differently here would recompute what that call already
539    /// knows — against a value that, if stale, could strand a task nobody
540    /// is left validated to drive.
541    ///
542    /// **Threading:** the terminal read is this call's own synchronous
543    /// prelude and runs through [`blocking`]; the wait after it is
544    /// [`attach::wait_for_terminal`]'s own — see its doc for where every lock
545    /// and fs read *it* makes runs, model turns included.
546    async fn wait_unvalidated(
547        &self,
548        handle: &TaskHandle,
549        timeout: Duration,
550        live: Option<Arc<dyn LiveSink>>,
551    ) -> Result<WaitOutcome, Error> {
552        let data = self.data.clone();
553        let target = handle.clone();
554        let resolved = blocking(move || -> Result<Option<Value>, Error> {
555            let paths = attach::resolve(&data, target.as_str()).map_err(Error::new)?;
556            state::read_terminal(&paths).map_err(Error::new)
557        })
558        .await?;
559        if let Some(terminal) = resolved {
560            return Ok(WaitOutcome::Terminal(TerminalRecord::from_raw(terminal)));
561        }
562        let ctx = self.ctx(live);
563        attach::wait_for_terminal(&self.data, handle.as_str(), timeout, &ctx)
564            .await
565            .map_err(Error::new)
566    }
567
568    /// Mints a task and immediately attaches to drive it to a terminal
569    /// result — `spawn --await`'s shape, and the one place a wait skips edge
570    /// validation: see this crate's private `wait_unvalidated` for why that
571    /// is safe only here.
572    ///
573    /// **Threading:** [`spawn`](Self::spawn) is a synchronous unit in its own
574    /// right — a directory scan, the continuation lock, `create_dir`,
575    /// `save_meta` — and runs through this crate's own `blocking` helper here
576    /// exactly as it would if this crate wrote it as its own
577    /// `blocking`-wrapped prelude; the wait after it is this crate's own
578    /// `wait_unvalidated`'s.
579    pub async fn spawn_and_wait(
580        &self,
581        spec: RunSpec,
582        timeout: Duration,
583        live: Option<Arc<dyn LiveSink>>,
584    ) -> Result<(TaskHandle, WaitOutcome), Error> {
585        let tasks = self.clone();
586        let handle = blocking(move || tasks.spawn(spec)).await?;
587        let outcome = self.wait_unvalidated(&handle, timeout, live).await?;
588        Ok((handle, outcome))
589    }
590
591    /// Whether `caller` (or nobody, for a host outside any task) may
592    /// [`wait`](Self::wait) or [`ask`](Self::ask) `target` — the ownership
593    /// rule ADR-0017 states: a descendant or an independent root is safe, an
594    /// ancestor or a peer is not (send it instead, and read the reply from
595    /// [`inbox`](Self::inbox)). `Err` names which rule the edge breaks; a
596    /// caller that only wants the yes/no can ask for `.is_ok()`.
597    pub fn validate_wait_edge(
598        &self,
599        caller: Option<&TaskHandle>,
600        target: &TaskHandle,
601    ) -> Result<(), Error> {
602        policy::validate_wait_edge(&self.data, caller.map(TaskHandle::as_str), target.as_str())
603            .map_err(Error::new)
604    }
605
606    /// Whether `caller` (or nobody, for a host outside any task) may
607    /// [`cancel`](Self::cancel) `target` — downward-only, ADR-0017's rule:
608    /// itself or a descendant, never an ancestor or a peer. `Err` names which
609    /// rule the target breaks; a caller that wants the refusal to win over an
610    /// idempotent observation of an already-settled target checks this
611    /// first, the way `basis cancel` does.
612    pub fn validate_cancel_target(
613        &self,
614        caller: Option<&TaskHandle>,
615        target: &TaskHandle,
616    ) -> Result<(), Error> {
617        policy::validate_cancel_target(&self.data, caller.map(TaskHandle::as_str), target.as_str())
618            .map_err(Error::new)
619    }
620
621    /// Requests downward cancellation of `target` and every attached,
622    /// non-terminal descendant. Idempotent: cancelling an already-settled
623    /// task is a no-op, and this call never blocks on one settling.
624    pub fn cancel(&self, handle: &TaskHandle, caller: Option<&TaskHandle>) -> Result<(), Error> {
625        // Existence is checked here rather than left to `cancel_tree`, which
626        // silently skips a directory that is not there: a caller cancelling a
627        // handle that never existed should hear about it.
628        attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
629        policy::validate_cancel_target(&self.data, caller.map(TaskHandle::as_str), handle.as_str())
630            .map_err(Error::new)?;
631        attach::cancel_tree(&self.data, handle.as_str()).map_err(Error::new)
632    }
633
634    /// Opens a cursor over the task's event journal, replay-from-start. Pure
635    /// observation — this never attaches or drives; poll it in a loop beside
636    /// [`terminal`](Self::terminal) for `basis watch`'s own shape, or just
637    /// long enough to catch up on a run already in progress.
638    pub fn watch(&self, handle: &TaskHandle) -> Result<EventCursor, Error> {
639        let paths = attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
640        Ok(EventCursor::new(EventTail::new(&paths, 0)))
641    }
642
643    /// The terminal record, raw and typed side by side, or `None` for a task
644    /// still resumable.
645    /// Repeatable and lock-free: existence of `terminal.json` *is* the
646    /// completion signal (ADR-0019).
647    pub fn terminal(&self, handle: &TaskHandle) -> Result<Option<TerminalRecord>, Error> {
648        let paths = attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
649        state::read_terminal(&paths)
650            .map(|record| record.map(TerminalRecord::from_raw))
651            .map_err(Error::new)
652    }
653
654    /// Whether a live executor currently holds the task's attach lock.
655    pub fn is_attached(&self, handle: &TaskHandle) -> Result<bool, Error> {
656        let paths = attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
657        Ok(lock::is_held(&paths.attach_lock()))
658    }
659
660    /// Every message accepted on the task's inbox, bounded 4 KiB summaries
661    /// with truncation metadata — the `basis inbox` payload shape.
662    pub fn inbox(&self, handle: &TaskHandle) -> Result<InboxRecord, Error> {
663        let paths = attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
664        let messages = inbox::load(&paths).map_err(Error::new)?;
665        Ok(inbox::inbox_record(handle.as_str(), &messages))
666    }
667
668    /// The workspace the task was spawned against, as it was recorded at
669    /// spawn — not necessarily `Tasks::open`'s own workspace, since a handle
670    /// resolves purely from itself.
671    pub fn workspace_of(&self, handle: &TaskHandle) -> Result<PathBuf, Error> {
672        let paths = attach::resolve(&self.data, handle.as_str()).map_err(Error::new)?;
673        let meta = state::load_meta(&paths).map_err(Error::new)?;
674        Ok(PathBuf::from(meta.workspace))
675    }
676
677    /// Every task recorded for this `Tasks`'s workspace, last worked in
678    /// first. Empty for a workspace nothing has ever run in — that is a
679    /// complete answer, not an error.
680    pub fn list(&self) -> Result<Vec<TaskSummary>, Error> {
681        Ok(tasks::workspace_tasks(&self.data, &self.workspace)
682            .map_err(Error::new)?
683            .unwrap_or_default())
684    }
685}
686
687fn duration_ms(duration: Duration) -> u64 {
688    duration.as_millis().min(u128::from(u64::MAX)) as u64
689}
690
691/// Runs one bounded, synchronous unit of this crate's own blocking work —
692/// a lock acquisition, an fs read or write, [`Tasks::spawn`]'s own mint — off
693/// the caller's async executor and onto tokio's blocking thread pool (G7's
694/// pattern, `ca9ddcb`, applied at this crate's own boundary).
695///
696/// Every `pub async fn` on [`Tasks`] that has a synchronous prelude (an edge
697/// check, an enqueue, a terminal read, `spawn` itself) runs it through this
698/// rather than inline on the caller's task — see each method's own
699/// `**Threading:**` note for which part that is. [`attach::wait_for_terminal`]
700/// and [`attach::wait_for_message`] carry the other half of the same rule for
701/// the poll loop and the model turns it drives.
702async fn blocking<T, F>(work: F) -> Result<T, Error>
703where
704    F: FnOnce() -> Result<T, Error> + Send + 'static,
705    T: Send + 'static,
706{
707    tokio::task::spawn_blocking(work)
708        .await
709        .unwrap_or_else(|error| Err(Error::new(format!("background task failed: {error}"))))
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::Approve;
716
717    fn tasks() -> (tempfile::TempDir, tempfile::TempDir, Tasks) {
718        let data_dir = tempfile::tempdir().unwrap();
719        let workspace = tempfile::tempdir().unwrap();
720        let tasks = Tasks::open_at(data_dir.path(), workspace.path()).unwrap();
721        (data_dir, workspace, tasks)
722    }
723
724    /// The general hazard `spawn_and_wait` exists to route around: an
725    /// ordinary `wait` refuses a caller that does not exist to validate the
726    /// edge against — the shape a stale `BASIS_TASK_ID` takes once its own
727    /// task directory is gone (archived, or simply never real).
728    #[tokio::test]
729    async fn wait_refuses_a_caller_that_does_not_exist() {
730        let (_data_dir, _workspace, tasks) = tasks();
731        let stale_caller = TaskHandle::parse(format!("{:016x}/{:032x}", 1, 1)).unwrap();
732        let handle = tasks
733            .spawn(
734                RunSpec::new("hello")
735                    .with_approve(Approve::Always)
736                    .detached(),
737            )
738            .expect("spawns");
739
740        let refused = tasks
741            .wait(&handle, Some(&stale_caller), Duration::from_secs(1), None)
742            .await
743            .expect_err("a caller that does not exist cannot be validated against");
744        assert!(refused.to_string().contains("does not exist"), "{refused}");
745    }
746
747    /// `spawn_and_wait` never asks the wait-edge question at all, so a stale
748    /// `BASIS_TASK_ID` — one naming a caller task that no longer exists —
749    /// cannot strand the task this same call just minted, the way routing a
750    /// `spawn --await` through the ordinary edge-validated `wait` used to.
751    #[tokio::test]
752    async fn spawn_and_wait_does_not_edge_validate_the_task_it_just_minted() {
753        let (_data_dir, _workspace, tasks) = tasks();
754        // A deadline so tight it has certainly passed by the time the attach
755        // that follows runs: `run_model` bails on it before ever opening a
756        // workspace or touching a provider, which is what keeps this test
757        // fast and network-free while still exercising a real attach.
758        let spec = RunSpec::new("hello")
759            .with_approve(Approve::Always)
760            .with_deadline(Duration::from_nanos(1))
761            .detached();
762
763        let (_handle, outcome) = tasks
764            .spawn_and_wait(spec, Duration::from_secs(5), None)
765            .await
766            .expect("spawn_and_wait never asks the wait-edge question at all");
767        let WaitOutcome::Terminal(payload) = outcome else {
768            panic!("a tight deadline settles immediately rather than timing the wait out");
769        };
770        assert_eq!(payload.raw["state"], "failed");
771        assert_eq!(payload.stopped_by, Some(basis::Bound::Deadline));
772    }
773
774    /// `Approve::Prompt` names a question nobody can answer without a
775    /// `PromptHost`; a `Tasks` with none refuses it at spawn, before a task
776    /// directory even exists, rather than minting one that could never make
777    /// progress — the doc on [`crate::Approve::Prompt`] promises exactly
778    /// this.
779    #[test]
780    fn spawn_refuses_prompt_mode_with_no_prompt_host() {
781        let (_data_dir, _workspace, tasks) = tasks();
782
783        let error = tasks
784            .spawn(RunSpec::new("hello").with_approve(Approve::Prompt))
785            .expect_err("no PromptHost means Prompt can never be answered");
786        assert!(error.to_string().contains("ask"), "{error}");
787    }
788
789    /// `is_attached` is documented as a read, and `list` asks the same
790    /// question of every task it reports. A read that leaves a file behind is
791    /// not one: probing a task nothing has ever attached must find no lock
792    /// and mint none, so a listing of a workspace is not also a write to
793    /// every task directory in it.
794    #[test]
795    fn probing_attachment_does_not_mint_the_lock_file() {
796        let (_data_dir, _workspace, tasks) = tasks();
797        let handle = tasks
798            .spawn(
799                RunSpec::new("hello")
800                    .with_approve(Approve::Always)
801                    .detached(),
802            )
803            .expect("spawns");
804        let paths = attach::resolve(&tasks.data, handle.as_str()).expect("agent dir");
805        assert!(
806            !paths.attach_lock().exists(),
807            "a task nothing has attached starts with no lock file"
808        );
809
810        assert!(!tasks.is_attached(&handle).expect("probes"));
811        let listed = tasks.list().expect("lists");
812        assert!(listed.iter().any(|task| task.task == handle.as_str()));
813
814        assert!(
815            !paths.attach_lock().exists(),
816            "observing a task must leave its directory as it found it"
817        );
818    }
819
820    /// T3: `wait`'s own lock and fs work — the poll loop's `resolve`,
821    /// `read_terminal`, `try_attach`, all of it — runs off the tokio worker
822    /// thread this test's (deliberately single-threaded) executor is. A
823    /// concurrent, purely async ticker sharing that one worker keeps making
824    /// progress the whole time `wait`'s poll loop sees a contended attach
825    /// lock, which a `wait` running any of that work inline could not
826    /// guarantee: a `current_thread` runtime has exactly one worker, and
827    /// blocking it stalls everything else scheduled there.
828    #[tokio::test(flavor = "current_thread")]
829    async fn wait_does_not_block_the_executor_it_is_called_from() {
830        let (_data_dir, _workspace, tasks) = tasks();
831        let handle = tasks
832            .spawn(
833                RunSpec::new("hello")
834                    .with_approve(Approve::Always)
835                    // Fails fast, without a network call, the moment it is
836                    // actually driven — this test's own point is entirely
837                    // about the poll loop leading up to that, not the turn.
838                    .with_provider("not-a-provider"),
839            )
840            .expect("spawns");
841        let paths = attach::resolve(&tasks.data, handle.as_str()).expect("agent dir");
842        // Stands in for another process already driving the task: `wait`'s
843        // poll loop sees a contended attach lock every iteration for a
844        // controlled stretch.
845        let held = attach::try_attach(&paths).unwrap().expect("lock is free");
846
847        let ticks = Arc::new(std::sync::atomic::AtomicUsize::new(0));
848        let counter = Arc::clone(&ticks);
849        let ticker = async move {
850            for _ in 0..40 {
851                tokio::time::sleep(Duration::from_millis(5)).await;
852                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
853            }
854        };
855        let releaser = async {
856            tokio::time::sleep(Duration::from_millis(200)).await;
857            drop(held);
858        };
859        let waiter = tasks.wait(&handle, None, Duration::from_secs(5), None);
860        let (outcome, (), ()) = tokio::join!(waiter, ticker, releaser);
861        outcome.expect("wait completes once the lock frees");
862        assert!(
863            ticks.load(std::sync::atomic::Ordering::SeqCst) >= 30,
864            "a ticker sharing this runtime's one worker thread kept making \
865             progress while wait's poll loop was contended — proof that \
866             loop's lock probes and fs reads never held that thread"
867        );
868    }
869
870    /// T2(a): a task minted with `continues = Some(agent_id)` holds an open
871    /// claim on that conversation until it settles — a second spawn that
872    /// would continue the same conversation refuses rather than mint a
873    /// second claimant, the double-continuation race `continue_lock` and
874    /// this check exist to close.
875    #[test]
876    fn a_second_continuation_of_the_same_conversation_is_refused_while_the_first_is_open() {
877        let (_data_dir, _workspace, tasks) = tasks();
878
879        // Stand in for a task that finished with a conversation to
880        // continue — built directly, the way `attach`'s own tests build a
881        // completed task, rather than driven through a real model.
882        let done = tasks
883            .spawn(RunSpec::new("hello").with_approve(Approve::Always))
884            .expect("spawns");
885        let paths = attach::resolve(&tasks.data, done.as_str()).expect("agent dir");
886        let mut meta = state::load_meta(&paths).expect("meta.json");
887        meta.agent_id = "conversation-1".to_string();
888        state::save_meta(&paths, &meta).expect("save");
889        state::write_terminal(
890            &paths,
891            &serde_json::json!({"state": "succeeded", "result": "d"}),
892        )
893        .expect("terminal");
894
895        let first = tasks
896            .spawn(
897                RunSpec::new("step two")
898                    .with_approve(Approve::Always)
899                    .continuing(crate::Continuation::Latest),
900            )
901            .expect("the first continuation claims the conversation");
902
903        let refused = tasks
904            .spawn(
905                RunSpec::new("step two, again")
906                    .with_approve(Approve::Always)
907                    .continuing(crate::Continuation::Latest),
908            )
909            .expect_err("a second, still-open claim on the same conversation is refused");
910        assert!(refused.to_string().contains(first.as_str()), "{refused}");
911
912        // Once the first claimant settles, its claim releases and a fresh
913        // continuation of the same conversation is ordinary again.
914        let first_paths = attach::resolve(&tasks.data, first.as_str()).expect("agent dir");
915        state::write_terminal(
916            &first_paths,
917            &serde_json::json!({"state": "succeeded", "result": "d2"}),
918        )
919        .expect("terminal");
920        tasks
921            .spawn(
922                RunSpec::new("step three")
923                    .with_approve(Approve::Always)
924                    .continuing(crate::Continuation::Latest),
925            )
926            .expect("a settled claimant no longer blocks the conversation");
927    }
928
929    /// ADR-0019: an unattended task always gets a finite service bound, even
930    /// when the caller named none — `attach::run_model` enforces deadlines
931    /// for agents nobody attached to in time, and that has nothing to bound
932    /// against without this.
933    #[test]
934    fn an_unset_deadline_records_the_default_at_spawn() {
935        let (_data_dir, _workspace, tasks) = tasks();
936
937        let handle = tasks
938            .spawn(RunSpec::new("hello").with_approve(Approve::Always))
939            .expect("spawns");
940
941        // Grey-box: `Tasks` exposes no deadline accessor — the durable
942        // record is basis-tasks's own business — so the test reads it the
943        // way `attach`'s own tests read `meta.json` directly.
944        let paths = attach::resolve(&tasks.data, handle.as_str()).expect("agent dir");
945        let meta = state::load_meta(&paths).expect("meta.json");
946        assert_eq!(
947            meta.options.deadline_ms,
948            Some(duration_ms(DEFAULT_DEADLINE))
949        );
950        assert!(
951            meta.deadline_at_ms.is_some(),
952            "an unattended task always gets a finite service bound"
953        );
954    }
955}