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