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