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