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