Skip to main content

basis_tasks/
attach.rs

1//! The attach protocol: one process becomes an agent's executor (ADR-0019).
2//!
3//! Attach takes the agent's `attach.lock` — one writer, ever — resumes the
4//! conversation from mentra's last committed turn, executes, and checkpoints
5//! at turn boundaries. `terminal.json`, written atomically as the executor's
6//! **last** act, is the completion signal; an agent is resumable iff it does
7//! not exist, and every crash before it resolves toward resumable.
8//!
9//! **Re-driving a turn may repeat its tool side effects.** A checkpoint
10//! restores state, never effects — a shell command that ran, ran. A turn a
11//! crash cut off before the model committed anything rolled back, and its
12//! prompt or message is driven again. One cut off after the model acted is
13//! *continued* instead (see [`resume`]): the calls that were running are
14//! closed as having an unknown effect, never re-executed by basis, and the
15//! model is asked to finish the turn — it is not answered, so it does not
16//! settle, and it is not asked again, so the prompt is not repeated.
17//!
18//! A parent's executor may not write its terminal record while an attached
19//! child lacks one: the settle pass here is the scope rule as one ordering
20//! constraint, with no resident supervisor to enforce it. The process attached
21//! to a parent supervises exactly its own subtree — it drives unfinished
22//! children whose locks are free and observes the ones with live executors.
23//!
24//! # Threading (T3, whole-wave review)
25//!
26//! Everything in this module that touches a lock or a file runs on tokio's
27//! blocking thread pool, never on a caller's own async worker thread — the
28//! same discipline G7 (`ca9ddcb`) applied to `basis`'s own memory discovery,
29//! at this crate's boundary instead. Concretely:
30//!
31//! - [`wait_for_terminal`] and [`wait_for_message`] are themselves plain
32//!   `async fn`s that never touch a lock or a file directly. Each poll
33//!   iteration's work — the terminal read, the non-blocking attach probe,
34//!   and (if it wins the attach) the drive itself — happens inside
35//!   [`poll_once`]/[`poll_message_once`], each one `tokio::task::spawn_blocking`
36//!   call. Between iterations, `tokio::time::sleep` is the only thing either
37//!   function awaits directly.
38//! - [`drive`]'s own model turns are real `async` work (network calls
39//!   through mentra), and they run *inside* that same blocking-pool thread:
40//!   `poll_once`/`poll_message_once` borrow a
41//!   [`Handle`](tokio::runtime::Handle) before spawning and call
42//!   [`Handle::block_on`] on it once attached, so `drive`, `run_model`,
43//!   `settle`, and `settle_children`'s own recursive `drive` calls all run
44//!   as one unit on that thread — none of their own lock or fs calls need a
45//!   second `spawn_blocking` of their own, and nesting one would be
46//!   redundant, not incorrect (they are already off any tokio worker
47//!   thread). Attempting the reverse — calling `Handle::block_on` from a
48//!   thread tokio is already using to drive async tasks — panics outright
49//!   ("cannot start a runtime from within a runtime"), which is exactly the
50//!   failure mode that makes this ordering load-bearing rather than
51//!   cosmetic.
52//! - [`is_attached`] (the one lock probe outside the poll loop, for a
53//!   timeout's own `attached` field) gets its own small `spawn_blocking` for
54//!   the same reason.
55//!
56//! `client.rs`'s own `blocking` helper carries the same rule for each public
57//! `async fn`'s synchronous prelude (an edge check, an enqueue, `spawn`
58//! itself) — see its doc.
59
60use std::{
61    io,
62    path::Path,
63    sync::{Arc, Mutex},
64    time::{Duration, UNIX_EPOCH},
65};
66
67use basis::{
68    AllowAll, Approver, Bound, CancellationToken, DenyAll, DenyAllGate, Event, EventSink,
69    PreparedRun, RunOutcome, RunSpec, Runtime, RuntimeBuilder, ShellAccess, TurnOptions, Workspace,
70    WorkspaceBuilder,
71};
72use serde_json::Value;
73use tokio::time::{self, Instant};
74
75use self::resume::{Recovered, Work, recover};
76use crate::{
77    Error,
78    approve::Approve,
79    data_dir::{AgentPaths, DataDir, valid_task_handle},
80    events::EventLog,
81    inbox,
82    live::DriveContext,
83    lock,
84    state::{
85        MAX_RESULT_BYTES, MAX_TASKS, MessageReply, TaskMeta, Terminal, TerminalRecord,
86        bounded_text, cancel_requested, load_meta, now_ms, read_terminal, request_cancel,
87        save_meta, write_terminal,
88    },
89};
90
91/// The polling cadence everything waits at: terminal records, contended
92/// locks, child settling. Bounded CPU, honest tail latency. Public so a host
93/// composing its own loop around [`crate::EventCursor`] — `basis watch`'s own
94/// loop, for one — polls at the same cadence this crate's own waits do.
95pub const POLL: Duration = Duration::from_millis(100);
96
97/// What a bounded wait produced: the settled payload, or a timeout with
98/// enough said about it to retry sensibly.
99#[derive(Debug, Clone, PartialEq)]
100pub enum WaitOutcome {
101    /// The terminal payload, with its exact JSON and typed fields side by
102    /// side. For `wait_for_message`, this is the correlated reply or
103    /// terminal-tagged payload `message_payload_for_dispatch` resolved.
104    Terminal(TerminalRecord),
105    /// The bounded wait elapsed; `attached` reports whether a live executor
106    /// held the lock at that moment.
107    TimedOut { attached: bool },
108}
109
110/// Waits for a task's terminal record, attaching to produce it whenever the
111/// lock is free. A contended lock means a live executor exists: observe.
112///
113/// `ctx` carries the caller's terminal, shown to while this process is the
114/// one executing, and its say over `Approve::Prompt`. Nothing is shown for a
115/// record that was merely read off disk: there is nothing live about a run
116/// that finished before this process asked.
117///
118/// `timeout` is saturated into a deadline (`Instant::now() + Duration::MAX`
119/// panics): a duration too large to represent as a deadline is waited
120/// forever rather than refused, which is what asking for one that large
121/// means.
122///
123/// The deadline binds the wait, not the task. Between polls it is checked
124/// here; while this process is the executor it travels into [`drive`] on
125/// `ctx` and is honored at every turn boundary, so a `wait` that attaches
126/// to a long task gives the lock back — with no terminal record, the task
127/// still resumable — once its own time is up. The one turn in flight at
128/// that moment is bounded by the task's deadline, not the wait's: that is
129/// ADR-0019's granularity, not a gap.
130///
131/// **Threading (G7, `ca9ddcb`, applied at this crate's own boundary):** this
132/// function itself never touches a lock or a file — [`poll_once`] and
133/// [`is_attached`] do that, each on its own `spawn_blocking` thread, so the
134/// `time::sleep` between iterations is the only thing this `async fn` ever
135/// awaits directly on the caller's executor.
136pub(crate) async fn wait_for_terminal(
137    data: &DataDir,
138    task: &str,
139    timeout: Duration,
140    ctx: &DriveContext,
141) -> Result<WaitOutcome, String> {
142    let deadline = Instant::now().checked_add(timeout);
143    let ctx = ctx.clone().until(deadline.map(Instant::into_std));
144    loop {
145        if let Some(terminal) = poll_once(data.clone(), task.to_string(), ctx.clone()).await? {
146            return Ok(WaitOutcome::Terminal(TerminalRecord::from_raw(terminal)));
147        }
148        // A host going away stops waiting at once, as if its time were up:
149        // the task is left to whoever attaches next.
150        if ctx.is_stopping() || deadline.is_some_and(|deadline| Instant::now() >= deadline) {
151            return Ok(WaitOutcome::TimedOut {
152                attached: is_attached(data, task).await?,
153            });
154        }
155        time::sleep(POLL).await;
156    }
157}
158
159/// One [`wait_for_terminal`] iteration, entirely on a blocking thread: the
160/// terminal read, the non-blocking attach probe, and — if this call wins the
161/// attach — driving the task via a runtime [`Handle`](tokio::runtime::Handle)
162/// borrowed for exactly that. `drive`'s own `.await`s (the model turns) still
163/// run correctly under this: `Handle::block_on` drives them to completion on
164/// this same blocking-pool thread rather than a tokio worker thread, which is
165/// the whole point — nothing this reaches (`resolve`, `read_terminal`,
166/// `try_attach`, every lock and fs read `drive`'s own call tree makes,
167/// `settle_children`'s recursive `drive` calls included) ever runs on one.
168///
169/// `None` means the iteration made no progress (nothing to attach to yet, or
170/// [`drive`] itself backed off — its conversation claimed elsewhere, or the
171/// waiter's own deadline reached at a turn boundary) — indistinguishable to
172/// the caller from "still running", which is exactly right: both just mean
173/// check the deadline, then try again next poll.
174async fn poll_once(
175    data: DataDir,
176    task: String,
177    ctx: DriveContext,
178) -> Result<Option<Value>, String> {
179    let handle = tokio::runtime::Handle::current();
180    tokio::task::spawn_blocking(move || -> Result<Option<Value>, String> {
181        let paths = resolve(&data, &task)?;
182        if let Some(terminal) = read_terminal(&paths)? {
183            return Ok(Some(terminal));
184        }
185        if ctx.is_stopping() {
186            return Ok(None);
187        }
188        match try_attach(&paths)? {
189            Some(guard) => handle.block_on(drive(&data, &task, guard, &ctx)),
190            None => Ok(None),
191        }
192    })
193    .await
194    .unwrap_or_else(|error| Err(format!("poll task: {error}")))
195}
196
197/// Whether a live executor currently holds `task`'s attach lock — the one
198/// lock probe [`wait_for_terminal`] and [`wait_for_message`] need outside
199/// their own poll loop, to answer a timeout's `attached` field. On a
200/// blocking thread, like every other lock touch in this module.
201async fn is_attached(data: &DataDir, task: &str) -> Result<bool, String> {
202    let data = data.clone();
203    let task = task.to_string();
204    tokio::task::spawn_blocking(move || -> Result<bool, String> {
205        let paths = resolve(&data, &task)?;
206        Ok(lock::is_held(&paths.attach_lock()))
207    })
208    .await
209    .unwrap_or_else(|error| Err(format!("check attach lock: {error}")))
210}
211
212/// Waits for one correlated message reply, attaching to produce it whenever
213/// the lock is free. Returns the dispatch payload (reply, or terminal tagged
214/// with the message id).
215///
216/// Nothing is shown while it drives. The caller asked for *one message's*
217/// reply, and the turns this process may have to run to reach it can belong
218/// to other messages entirely — streaming them would answer a question nobody
219/// asked, on the stream the answer is supposed to arrive on. `Approve::Prompt`
220/// still answers through `prompt_host`, because approval and visibility are
221/// independent facts.
222///
223/// `timeout` is saturated into a deadline, as [`wait_for_terminal`]'s is: a
224/// duration too large to represent as a deadline waits forever rather than
225/// panicking or refusing — and, as there, it bounds the wait rather than
226/// the task: past it no further turn starts under this process, the one in
227/// flight finishes under the task's own deadline, and the lock is released
228/// with the message still pending for whoever attaches next.
229///
230/// **Threading:** as [`wait_for_terminal`] — [`poll_message_once`] carries
231/// every lock and fs touch this makes onto a blocking thread; this `async fn`
232/// only ever awaits that and, between iterations, `time::sleep`.
233pub(crate) async fn wait_for_message(
234    data: &DataDir,
235    task: &str,
236    message_id: &str,
237    timeout: Duration,
238    ctx: DriveContext,
239) -> Result<WaitOutcome, String> {
240    let deadline = Instant::now().checked_add(timeout);
241    let ctx = ctx.until(deadline.map(Instant::into_std));
242    loop {
243        match poll_message_once(
244            data.clone(),
245            task.to_string(),
246            message_id.to_string(),
247            ctx.clone(),
248        )
249        .await?
250        {
251            MessagePoll::Resolved(payload) => {
252                return Ok(WaitOutcome::Terminal(TerminalRecord::from_raw(payload)));
253            }
254            // A turn ran (for this message or another) but did not resolve
255            // ours: recheck immediately, the way the pre-thread-split loop
256            // did with its own `continue` — no reason to sleep when there is
257            // fresh state to read.
258            MessagePoll::Drove => continue,
259            MessagePoll::Idle => {}
260        }
261        if ctx.is_stopping() || deadline.is_some_and(|deadline| Instant::now() >= deadline) {
262            return Ok(WaitOutcome::TimedOut {
263                attached: is_attached(data, task).await?,
264            });
265        }
266        time::sleep(POLL).await;
267    }
268}
269
270/// What one [`wait_for_message`] iteration found.
271enum MessagePoll {
272    /// The message's dispatch payload — a reply, or a terminal record tagged
273    /// with the message id.
274    Resolved(Value),
275    /// This iteration drove a turn (this task's, via [`drive`]) but it was
276    /// not the one the caller is waiting on; state may have changed, so the
277    /// next iteration should look again before waiting out the poll cadence.
278    Drove,
279    /// Nothing to read and nothing to attach to (or attaching was
280    /// contended); the ordinary "still waiting" case.
281    Idle,
282}
283
284/// One [`wait_for_message`] iteration, entirely on a blocking thread — see
285/// [`poll_once`], which this is the message-scoped twin of: it checks
286/// `message_id`'s own dispatch payload rather than only the task's terminal,
287/// and only attaches while the task itself has no terminal yet.
288async fn poll_message_once(
289    data: DataDir,
290    task: String,
291    message_id: String,
292    ctx: DriveContext,
293) -> Result<MessagePoll, String> {
294    let handle = tokio::runtime::Handle::current();
295    tokio::task::spawn_blocking(move || -> Result<MessagePoll, String> {
296        let paths = resolve(&data, &task)?;
297        let messages = inbox::load(&paths)?;
298        let terminal = read_terminal(&paths)?;
299        if let Some(payload) =
300            inbox::message_payload_for_dispatch(&task, &messages, &message_id, terminal.as_ref())?
301        {
302            return Ok(MessagePoll::Resolved(payload));
303        }
304        if terminal.is_none()
305            && !ctx.is_stopping()
306            && let Some(guard) = try_attach(&paths)?
307        {
308            // `None` is a drive that made no progress — its conversation
309            // claimed elsewhere, or the waiter's deadline reached at a turn
310            // boundary — and reporting it as `Drove` would send the caller
311            // straight back here to attach and yield again, never reaching
312            // its own deadline check. It is `Idle`: nothing changed.
313            return Ok(match handle.block_on(drive(&data, &task, guard, &ctx))? {
314                Some(_) => MessagePoll::Drove,
315                None => MessagePoll::Idle,
316            });
317        }
318        Ok(MessagePoll::Idle)
319    })
320    .await
321    .unwrap_or_else(|error| Err(format!("poll message: {error}")))
322}
323
324pub(crate) fn resolve(data: &DataDir, task: &str) -> Result<AgentPaths, String> {
325    data.agent_dir(task)
326        .filter(AgentPaths::exists)
327        .ok_or_else(|| format!("no task directory for {task}"))
328}
329
330pub(crate) fn try_attach(paths: &AgentPaths) -> Result<Option<lock::Lock>, String> {
331    lock::try_exclusive(&paths.attach_lock())
332        .map_err(|error| format!("acquire task attach lock: {error}"))
333}
334
335/// Requests downward cancellation: markers for the target and every attached
336/// (non-detached, non-terminal) descendant, honored at each executor's next
337/// turn boundary — or at the next attach for an agent nobody holds.
338pub(crate) fn cancel_tree(data: &DataDir, task: &str) -> Result<(), String> {
339    let mut queue = vec![task.to_string()];
340    let mut visited = 0_usize;
341    while let Some(current) = queue.pop() {
342        visited += 1;
343        if visited > MAX_TASKS {
344            break;
345        }
346        let Some(paths) = data.agent_dir(&current).filter(AgentPaths::exists) else {
347            continue;
348        };
349        if read_terminal(&paths)?.is_some() {
350            continue;
351        }
352        if !cancel_requested(&paths) {
353            request_cancel(&paths, Some(task))?;
354        }
355        queue.extend(children_of(data, &current)?);
356    }
357    Ok(())
358}
359
360/// The attached (non-detached) children of `task`, terminal or not.
361fn children_of(data: &DataDir, task: &str) -> Result<Vec<String>, String> {
362    let Some((key, _)) = valid_task_handle(task) else {
363        return Ok(Vec::new());
364    };
365    let agents = data.agents_dir(key);
366    let entries = match std::fs::read_dir(&agents) {
367        Ok(entries) => entries,
368        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
369        Err(error) => return Err(format!("scan workspace agents: {error}")),
370    };
371    let mut children = Vec::new();
372    for entry in entries {
373        let entry = entry.map_err(|error| format!("scan workspace agents: {error}"))?;
374        let id = entry.file_name().to_string_lossy().into_owned();
375        let handle = format!("{key}/{id}");
376        let Some(paths) = data.agent_dir(&handle) else {
377            continue;
378        };
379        let Ok(meta) = load_meta(&paths) else {
380            continue;
381        };
382        if !meta.detached && meta.parent.as_deref() == Some(task) {
383            children.push(handle);
384        }
385    }
386    Ok(children)
387}
388
389/// Executes the agent to its terminal record while holding the attach lock,
390/// and returns the raw terminal payload — or `None` when this attempt did
391/// not settle the task: the conversation it would resume is already claimed
392/// by another task's executor (see [`try_conversation`]), or the waiter
393/// `ctx` drives for ran out of time at a turn boundary (see
394/// [`DriveContext::until`]). Either way the lock this call did hold (this
395/// task's own attach lock, dropped with `guard` on any return) is released
396/// with no terminal record written, so the task is exactly as resumable as
397/// before, and the caller's poll loop checks its own deadline and retries —
398/// the same observe-don't-race contract a contended [`try_attach`] already
399/// means to its own caller, one layer down. For the claimed-conversation
400/// case that is T2(b)'s double-continuation race; for the timed-out waiter
401/// it is README's "waiting is not owning".
402///
403/// There is no residue to accept here any more. A "…for this session"
404/// approval answered during a drive used to persist in the store's
405/// `rules.json` until the task's next attach, whose resume cleared it — so a
406/// task nobody ever reattached kept those rows, reasons included,
407/// indefinitely. mentra 0.27's `PermissionRuleScope::Process` (mentra#53) is
408/// what basis's approval flow remembers into now: a rung owned by the live
409/// session driving this attempt, never written to the store. It dies with
410/// this process — whichever of the eight returns below ends it, deadline
411/// abandonment and a crash included — and there is nothing left for a next
412/// attach to clear.
413pub(crate) async fn drive(
414    data: &DataDir,
415    task: &str,
416    mut guard: lock::Lock,
417    ctx: &DriveContext,
418) -> Result<Option<Value>, String> {
419    let paths = resolve(data, task)?;
420    // Someone may have finished the task between our probe and our lock.
421    if let Some(terminal) = read_terminal(&paths)? {
422        return Ok(Some(terminal));
423    }
424    guard.write_fingerprint();
425    let mut meta = load_meta(&paths)?;
426    if meta.pending_terminal.is_none() {
427        let turns = match existing_conversation(&meta) {
428            Some(agent_id) => {
429                let (key, _) = valid_task_handle(task)
430                    .ok_or_else(|| format!("malformed task handle {task}"))?;
431                match try_conversation(data, key, &agent_id)? {
432                    Some(_conversation) => run_model(data, task, &paths, &mut meta, ctx).await?,
433                    None => return Ok(None),
434                }
435            }
436            None => run_model(data, task, &paths, &mut meta, ctx).await?,
437        };
438        if matches!(turns, Turns::Yielded) {
439            return Ok(None);
440        }
441    }
442    settle(data, &paths, &mut meta, ctx).await
443}
444
445/// How [`run_model`]'s turn loop ended.
446#[derive(Debug, PartialEq, Eq)]
447enum Turns {
448    /// A completion is recorded in `meta.pending_terminal`; the settle pass
449    /// turns it into the terminal record.
450    Recorded,
451    /// The waiter's own deadline passed at a turn boundary with work still
452    /// queued, or the host is going away. Nothing is recorded, nothing is
453    /// settled: the next attach picks the task up at exactly this boundary —
454    /// or, for a turn the host abandoned, wherever its conversation stands.
455    Yielded,
456}
457
458/// The conversation this task's next turn resumes, if it resumes one at all
459/// — its own prior attach, or what it was minted to continue. `None` is a
460/// brand-new conversation, the only case [`try_conversation`] is skipped for:
461/// nothing else can already be driving a conversation that does not exist
462/// yet. The same two-branch read [`run_model`] makes of `reattached`, kept in
463/// one place so `drive`'s pre-check and `run_model`'s own resume agree by
464/// construction.
465fn existing_conversation(meta: &TaskMeta) -> Option<String> {
466    if meta.agent_id.is_empty() {
467        meta.continues.clone()
468    } else {
469        Some(meta.agent_id.clone())
470    }
471}
472
473/// Tries one conversation's lock, non-blocking — the conversation-scoped
474/// counterpart of [`try_attach`]. Two tasks that both record `continues`
475/// against the same agent id (T2's double-continuation race) — or, on a
476/// reattach, a second process holding a stale idea of this same task — must
477/// not both call `Workspace::resume` on it at once; `None` here means
478/// somebody already is, and the caller's contract is to observe that, not
479/// race it.
480fn try_conversation(
481    data: &DataDir,
482    key: &str,
483    agent_id: &str,
484) -> Result<Option<lock::Lock>, String> {
485    let path = data
486        .conversation_lock(key, agent_id)
487        .map_err(|error| format!("prepare conversation lock: {error}"))?;
488    lock::try_exclusive(&path).map_err(|error| format!("acquire conversation lock: {error}"))
489}
490
491/// Runs the recorded work to a pending completion — or to the turn boundary
492/// at which the waiter `ctx` drives for gave up ([`Turns::Yielded`]). Model,
493/// configuration, and workspace failures become `Failed` completions; only
494/// metadata-persistence failures propagate as errors, leaving the agent
495/// resumable.
496async fn run_model(
497    data: &DataDir,
498    task: &str,
499    paths: &AgentPaths,
500    meta: &mut TaskMeta,
501    ctx: &DriveContext,
502) -> Result<Turns, String> {
503    // A host going away records nothing, not even a deadline or a cancel
504    // that is already due: those land on the next attach just the same.
505    if ctx.is_stopping() {
506        return Ok(Turns::Yielded);
507    }
508    if meta.deadline_passed() {
509        return record_pending(
510            paths,
511            meta,
512            Terminal::Failed {
513                error: "task deadline elapsed before the next turn".to_string(),
514            },
515            Some(Bound::Deadline),
516        );
517    }
518    // A cancel before any turn — on a never-attached or between-attaches
519    // agent — settles without opening a workspace or touching the model.
520    if cancel_requested(paths) {
521        return record_pending(paths, meta, Terminal::Cancelled, None);
522    }
523    let reverted = inbox::revert_in_flight(paths)?;
524
525    let events = match EventLog::open(paths) {
526        Ok(log) => Arc::new(Mutex::new(log)),
527        Err(error) => {
528            return record_failure(
529                paths,
530                meta,
531                format!("open task event journal: {error}"),
532                None,
533            );
534        }
535    };
536    // Ahead of the run config: the first unusable option is what the task
537    // fails with, and `--provider` was read before `--effort` while both
538    // halves of the options lived in one config.
539    let runtime = match task_runtime(data, task, meta) {
540        Ok(runtime) => runtime,
541        Err(error) => return record_failure(paths, meta, error, None),
542    };
543    let (builder, spec) = match run_parts(meta, runtime, paths) {
544        Ok(parts) => parts,
545        Err(error) => return record_failure(paths, meta, error, None),
546    };
547    let workspace = match builder.open().await {
548        Ok(workspace) => Arc::new(workspace),
549        Err(error) => return record_failure(paths, meta, error.to_string(), None),
550    };
551    // An attach drives turns for as long as the inbox has work — the CLI's
552    // attended session and every task the A2A server accepts both run here —
553    // so an agent the operator adds, moves or re-credentials meanwhile should
554    // be heard without a restart (ADR-0031 §4). Held to the end of this
555    // attach; dropping it stops the poll.
556    let _a2a_watch = workspace
557        .watch_a2a(basis::DEFAULT_A2A_WATCH_INTERVAL)
558        .inspect_err(|error| eprintln!("Warning: {error}; A2A files will not be watched"))
559        .ok();
560    // The run carries the workspace through the whole turn loop below, not
561    // just the mint: the workspace's hook registration and MCP connections
562    // end when it drops, and a task's `.basis/hooks.json` must keep its say over
563    // every turn (see `PreparedRun::with_workspace`).
564    //
565    // Three ways to open the conversation, and only the first is a *re*-open:
566    // a task that has attached before picks its own agent back up, a task
567    // minted with `--continue`/`--session` picks up the one it was told to
568    // continue, and everything else starts a new one. The middle case is a
569    // resume to mentra and a first attach to basis — its prompt has not been
570    // asked yet, which is exactly what `answered_before` below preserves.
571    let reattached = !meta.agent_id.is_empty();
572    let existing = existing_conversation(meta);
573    let prepared = match existing.as_deref() {
574        Some(agent_id) => workspace.resume(agent_id, spec),
575        None => workspace.prepare(spec),
576    };
577    // Gated here rather than beside the per-turn `approver` below, and that is
578    // forced: mentra's attachment has to be in place before the first turn, and
579    // this task's recorded mode cannot change while this process drives it.
580    let mut run = match prepared {
581        Ok(run) => gated(run.with_workspace(workspace), meta.options.approve),
582        Err(error) => return record_failure(paths, meta, error.to_string(), None),
583    };
584    if !reattached {
585        meta.agent_id = run.agent_id().to_string();
586        meta.answered_before = run.answered_turns();
587        meta.updated_ms = now_ms();
588        save_meta(paths, meta)?;
589    }
590
591    let Recovered {
592        mut initial_done,
593        mut interrupted,
594        mut last_result,
595    } = if reattached {
596        recover(paths, meta, &run, &reverted)?
597    } else {
598        Recovered::default()
599    };
600    let mut last_stopped_by: Option<Bound> = None;
601
602    let cancellation = CancellationToken::default();
603    loop {
604        // The turn boundary: cancel markers and deadlines — the task's and
605        // the waiter's — are honored here. A turn already running is cut
606        // short by exactly three of them, below, around the execution
607        // itself: the task's own deadline, a cancel marker, and the host
608        // going away, which records nothing at all.
609        if ctx.is_stopping() {
610            return yielded(paths, meta);
611        }
612        if cancel_requested(paths) {
613            return record_pending(paths, meta, Terminal::Cancelled, None);
614        }
615        let remaining = remaining_deadline(meta.deadline_at_ms);
616        if remaining.as_ref().is_some_and(Duration::is_zero) {
617            return record_pending(
618                paths,
619                meta,
620                Terminal::Failed {
621                    error: "task deadline elapsed before the next turn".to_string(),
622                },
623                Some(Bound::Deadline),
624            );
625        }
626        // The waiter's deadline stops the *next turn*, not the task: with
627        // one still to run (the prompt, or a pending message) this attach
628        // ends here, meta saved, nothing recorded, so the next attach
629        // resumes at this same boundary. With nothing left to run there is
630        // no turn to withhold, and the completion below is recorded as
631        // usual — a finished task is not kept unsettled by an impatient
632        // observer. Checked before `start_next`, which would otherwise mark
633        // a message in flight that this process is not going to drive.
634        if ctx.waiter_expired()
635            && (!initial_done || interrupted.is_some() || inbox::has_pending(paths)?)
636        {
637            return yielded(paths, meta);
638        }
639        let work = if let Some(message) = interrupted.take() {
640            Work::Continue(message)
641        } else if !initial_done {
642            Work::Prompt
643        } else if let Some((id, body)) = inbox::start_next(paths)? {
644            Work::Message(id, body)
645        } else {
646            let (result, truncated) = bounded_text(last_result, MAX_RESULT_BYTES);
647            meta.result_truncated = truncated;
648            return record_pending(paths, meta, Terminal::Succeeded { result }, last_stopped_by);
649        };
650
651        let mut turn = TurnOptions::default().with_cancel(cancellation.clone());
652        // The recorded instant itself, not what was left of it a moment ago:
653        // a relative deadline re-anchored at turn start drifts later by
654        // however long the turn took to start, and a task reached over A2A
655        // hands this deadline on, where it may only ever shrink.
656        if let Some(deadline_at) = meta.deadline_at_ms {
657            turn = turn.with_absolute_deadline(UNIX_EPOCH + Duration::from_millis(deadline_at));
658        }
659        let approver = match approver(meta.options.approve, ctx) {
660            Ok(approver) => approver,
661            Err(error) => return record_failure(paths, meta, error.to_string(), None),
662        };
663        let sink = FileSink {
664            log: Arc::clone(&events),
665            ctx: ctx.clone(),
666        };
667        let completed_message = work.message_id().map(str::to_owned);
668        let execution = async {
669            match work {
670                Work::Message(_, body) => run.send_with_options(body, sink, approver, turn).await,
671                Work::Prompt => {
672                    run.execute_with_approver_and_options(sink, approver, turn)
673                        .await
674                }
675                Work::Continue(_) => {
676                    run.continue_interrupted_with_options(sink, approver, turn)
677                        .await
678                }
679            }
680        };
681        let bounded = async {
682            match remaining {
683                Some(remaining) => time::timeout(remaining, execution).await.ok(),
684                None => Some(execution.await),
685            }
686        };
687        // The host going away abandons the turn where it stands, exactly as
688        // a crash would: what it committed stays in the conversation for the
689        // next attach to continue (or roll back and re-drive), and nothing
690        // about it is recorded here. The stop is looked at first: a host
691        // whose runtime is being torn down must not poll the turn again, and
692        // a turn that finished in the same instant is read back off its
693        // conversation by the next attach all the same.
694        // A cancel marker cuts the turn short too: a task asked to stop
695        // should not finish an hour-long tool round first, and an A2A
696        // caller's `CancelTask` expects the task it gets back to say
697        // CANCELED. The turn is cancelled, not dropped, so mentra unwinds it
698        // and the outcome below — an error, or a success that won the race —
699        // is read with the marker set, which settles it as cancelled.
700        let cancelled = async {
701            while !cancel_requested(paths) {
702                time::sleep(CANCEL_POLL).await;
703            }
704            cancellation.cancel();
705            std::future::pending::<()>().await
706        };
707        let report = tokio::select! {
708            biased;
709            () = ctx.stopping() => {
710                cancellation.cancel();
711                return yielded(paths, meta);
712            }
713            report = bounded => report,
714            () = cancelled => unreachable!("the cancel watch never finishes"),
715        };
716        let Some(report) = report else {
717            cancellation.cancel();
718            return record_pending(
719                paths,
720                meta,
721                Terminal::Failed {
722                    error: "task deadline elapsed during the turn".to_string(),
723                },
724                Some(Bound::Deadline),
725            );
726        };
727        let report = match report {
728            Ok(report) => report,
729            Err(_) if ctx.is_stopping() => return yielded(paths, meta),
730            Err(_) if cancel_requested(paths) => {
731                return record_pending(paths, meta, Terminal::Cancelled, None);
732            }
733            Err(error) => return record_failure(paths, meta, error.to_string(), None),
734        };
735        // Banked per turn, not per attach: a task settles under one terminal
736        // record but its turns may be driven by several processes, and a
737        // crash between two of them must not un-spend what the first one did.
738        meta.usage = meta.usage.plus(report.usage);
739        meta.updated_ms = now_ms();
740        save_meta(paths, meta)?;
741
742        let stopped_by = report.stopped_by;
743        match report.outcome {
744            // Failed as the host went away, quite possibly because it did: not
745            // an outcome anyone may be told. mentra rolled the turn back.
746            RunOutcome::Error { .. } if ctx.is_stopping() => return yielded(paths, meta),
747            RunOutcome::Error { message } => {
748                return if cancel_requested(paths) {
749                    record_pending(paths, meta, Terminal::Cancelled, None)
750                } else {
751                    record_failure(paths, meta, message, stopped_by)
752                };
753            }
754            RunOutcome::Ok => {
755                let result = report.final_message.unwrap_or_default();
756                if let Some(id) = completed_message {
757                    let (reply, result_truncated) = bounded_text(result.clone(), MAX_RESULT_BYTES);
758                    inbox::finish(
759                        paths,
760                        &id,
761                        Some(MessageReply {
762                            result: reply,
763                            result_truncated,
764                            stopped_by,
765                        }),
766                    )?;
767                }
768                initial_done = true;
769                last_result = result;
770                last_stopped_by = stopped_by;
771            }
772            // An outcome this build does not know — the enum is
773            // `#[non_exhaustive]` — is recorded as the failure it is rather
774            // than guessed into a success.
775            outcome => {
776                return record_failure(
777                    paths,
778                    meta,
779                    format!("unrecognized run outcome: {}", outcome.type_tag()),
780                    stopped_by,
781                );
782            }
783        }
784    }
785}
786
787/// Ends this attach at a turn boundary with nothing recorded: `meta` saved
788/// as it stands, the task as resumable as before.
789fn yielded(paths: &AgentPaths, meta: &mut TaskMeta) -> Result<Turns, String> {
790    meta.updated_ms = now_ms();
791    save_meta(paths, meta)?;
792    Ok(Turns::Yielded)
793}
794
795/// Records a completion on `meta`, durably. Always [`Turns::Recorded`] on
796/// success, so a turn loop can `return record_pending(..)` as its own
797/// verdict.
798fn record_pending(
799    paths: &AgentPaths,
800    meta: &mut TaskMeta,
801    completion: Terminal,
802    stopped_by: Option<Bound>,
803) -> Result<Turns, String> {
804    if matches!(completion, Terminal::Cancelled) {
805        meta.result_truncated = false;
806        meta.stopped_by = None;
807    } else {
808        meta.stopped_by = stopped_by;
809    }
810    meta.pending_terminal = Some(completion);
811    meta.updated_ms = now_ms();
812    save_meta(paths, meta)?;
813    Ok(Turns::Recorded)
814}
815
816fn record_failure(
817    paths: &AgentPaths,
818    meta: &mut TaskMeta,
819    message: String,
820    stopped_by: Option<Bound>,
821) -> Result<Turns, String> {
822    let (error, _) = bounded_text(message, MAX_RESULT_BYTES);
823    meta.result_truncated = false;
824    record_pending(paths, meta, Terminal::Failed { error }, stopped_by)
825}
826
827/// The settle pass: parent scope as one ordering constraint, then two writes
828/// under one hold of the inbox lock — the unanswered sweep, then the
829/// terminal record, in that order and no other. A concurrent enqueue either
830/// lands before the sweep or is refused by the terminal record it would
831/// otherwise miss; a crash between the two writes leaves the sweep durable
832/// and no terminal record, so the task is still resumable and the next
833/// attach's `meta.pending_terminal` sends it straight back here — see
834/// [`inbox::finish_unanswered_durably`] for why the order is the other way
835/// round from how it reads.
836///
837/// `None` when the host is going away: nothing is written, and the
838/// completion already on `meta` is settled by the next attach.
839async fn settle(
840    data: &DataDir,
841    paths: &AgentPaths,
842    meta: &mut TaskMeta,
843    ctx: &DriveContext,
844) -> Result<Option<Value>, String> {
845    if ctx.is_stopping() {
846        return Ok(None);
847    }
848    reconsider_cancel(paths, meta)?;
849    let cancel_children = !matches!(meta.pending_terminal, Some(Terminal::Succeeded { .. }));
850    if !settle_children(data, meta, cancel_children, ctx).await? {
851        return Ok(None);
852    }
853    // A cancel that arrived while children settled still lands before the
854    // terminal record, exactly as the daemon replaced a pending completion.
855    reconsider_cancel(paths, meta)?;
856
857    let payload = meta
858        .terminal_payload()
859        .expect("a completion was recorded before settling");
860    let _inbox_lock = inbox::finish_unanswered_durably(paths)?;
861    write_terminal(paths, &payload)?;
862    Ok(Some(payload))
863}
864
865fn reconsider_cancel(paths: &AgentPaths, meta: &mut TaskMeta) -> Result<(), String> {
866    if cancel_requested(paths) && !matches!(meta.pending_terminal, Some(Terminal::Cancelled)) {
867        record_pending(paths, meta, Terminal::Cancelled, None)?;
868    }
869    Ok(())
870}
871
872/// Blocks until every attached child holds a terminal record. Children whose
873/// locks are free are driven here — the attached process is the supervisor of
874/// its own subtree; children with live executors are observed. A failing or
875/// cancelled parent cancels its children first; a parent past its own
876/// deadline stops waiting politely and cancels too (its children's deadlines
877/// can only be narrower, so this converges). `false` when the host went away
878/// before they all had.
879async fn settle_children(
880    data: &DataDir,
881    meta: &TaskMeta,
882    cancel_children: bool,
883    ctx: &DriveContext,
884) -> Result<bool, String> {
885    loop {
886        if ctx.is_stopping() {
887            return Ok(false);
888        }
889        let mut unfinished = Vec::new();
890        for child in children_of(data, &meta.id)? {
891            let Some(paths) = data.agent_dir(&child).filter(AgentPaths::exists) else {
892                continue;
893            };
894            if read_terminal(&paths)?.is_none() {
895                unfinished.push((child, paths));
896            }
897        }
898        if unfinished.is_empty() {
899            return Ok(true);
900        }
901        let cancel = cancel_children || meta.deadline_passed();
902        let mut remaining = false;
903        for (child, paths) in unfinished {
904            if cancel && !cancel_requested(&paths) {
905                request_cancel(&paths, Some(&meta.id))?;
906            }
907            match try_attach(&paths)? {
908                Some(guard) => {
909                    // A child driven here is somebody else's run: this
910                    // process is finishing it to keep the scope rule, not
911                    // showing it to whoever asked about the parent. `None`
912                    // (its conversation is claimed elsewhere) leaves it
913                    // unfinished for the next pass, same as a contended
914                    // attach lock.
915                    if Box::pin(drive(data, &child, guard, &ctx.hidden()))
916                        .await?
917                        .is_none()
918                    {
919                        remaining = true;
920                    }
921                }
922                None => remaining = true,
923            }
924        }
925        if remaining {
926            time::sleep(POLL).await;
927        }
928    }
929}
930
931/// Applies the recorded process/workspace options to the runtime recipe, then
932/// builds this turn's run spec.
933fn run_parts(
934    meta: &TaskMeta,
935    runtime: RuntimeBuilder,
936    paths: &AgentPaths,
937) -> Result<(WorkspaceBuilder, RunSpec), String> {
938    let options = &meta.options;
939    let (runtime, mut builder) = crate::configure_builders(
940        runtime,
941        Workspace::builder(Path::new(&meta.workspace)),
942        options.provider.as_deref(),
943        options.base_url.as_deref(),
944        options.model.as_deref(),
945        ShellAccess::from_flag(!options.no_shell),
946    )
947    .map_err(|error| error.to_string())?;
948    builder = builder.with_runtime_builder(runtime);
949    // Recorded as the type it is; `load_meta` has already folded the
950    // pre-0.6 two-string spelling into this one field.
951    if let Some(system_prompt) = options.system_prompt.clone() {
952        builder = builder.with_system_prompt(system_prompt);
953    }
954    // A task answering a caller can hand back files; no other has anyone to
955    // hand them to (ADR-0041 §7).
956    if options.answering {
957        builder = builder.with_tool(crate::answer::AttachFile::new(paths.dir().to_path_buf()));
958    }
959
960    let mut spec = RunSpec::new(meta.prompt.clone());
961    if let Some(effort) = options.effort {
962        spec = spec.with_effort(effort);
963    }
964    if let Some(remaining) = remaining_deadline(meta.deadline_at_ms) {
965        spec = spec.with_deadline(remaining.max(Duration::from_millis(1)));
966    }
967    if let Some(tool_budget) = options.tool_budget {
968        spec = spec.with_tool_budget(tool_budget);
969    }
970    if let Some(token_budget) = options.token_budget {
971        spec = spec.with_token_budget(token_budget);
972    }
973    Ok((builder, spec))
974}
975
976/// The base recipe for this task's own runtime: store and command identity.
977/// [`run_parts`] applies the recorded provider and endpoint through the same
978/// concrete builder mapping as the attended CLI route.
979///
980/// The exported `BASIS_DATA_DIR` is absolute because `DataDir` resolves its
981/// root once at construction (see `data_dir::absolutize`): a child re-reads
982/// this variable from its own working directory, so a relative value here
983/// would name a second data directory rather than this one.
984///
985/// One runtime per task (ADR-0018): the environment below names *this* task,
986/// and a runtime's command environment is fixed for every workspace on it, so
987/// two concurrent tasks sharing one runtime would tell their subprocesses the
988/// same task id. The store lands under the workspace's key, which is what
989/// lets any later process resume any agent.
990fn task_runtime(data: &DataDir, task: &str, meta: &TaskMeta) -> Result<RuntimeBuilder, String> {
991    let (key, _) =
992        valid_task_handle(task).ok_or_else(|| format!("malformed task handle {task}"))?;
993    let mut runtime = Runtime::builder()
994        .with_store_dir(data.store_dir(key))
995        .with_command_environment(crate::BASIS_TASK_ID, task)
996        .with_command_environment(crate::BASIS_DATA_DIR, data.root().to_string_lossy());
997    if let Some(parent) = &meta.parent {
998        runtime = runtime.with_command_environment(crate::BASIS_PARENT_TASK_ID, parent);
999    }
1000    if meta.hop > 0 {
1001        // Twice, for two readers: a command the task runs reads the
1002        // environment, and the task's own `@agent` delegations read the
1003        // runtime, so each remote agent it reaches is told the next hop.
1004        runtime = runtime
1005            .with_command_environment(crate::BASIS_HOP, meta.hop.to_string())
1006            .with_hop(meta.hop);
1007    }
1008    Ok(runtime)
1009}
1010
1011/// `mode`'s approver for one attach, given what this process brought to it.
1012///
1013/// Under ADR-0019 the executor is whichever process holds the attach lock, so
1014/// whether `Prompt` is answerable is a property of the attacher rather than
1015/// of the task — see [`PromptHost`](crate::approve::PromptHost).
1016fn approver(mode: Approve, ctx: &DriveContext) -> Result<Box<dyn Approver>, Error> {
1017    crate::approve::validate_approval(mode, ctx.can_ask())?;
1018    Ok(match mode {
1019        Approve::Always => Box::new(AllowAll),
1020        Approve::Never => Box::new(DenyAll),
1021        // `ctx.can_ask()` having just returned true is what makes this
1022        // `expect` honest: `validate` above already refused `Prompt` for a
1023        // context with no host, or one that cannot currently ask.
1024        Approve::Prompt => ctx
1025            .approver()
1026            .expect("validate confirmed a host that can ask"),
1027    })
1028}
1029
1030/// The session authorizer `mode` needs over the runtime's, if any.
1031///
1032/// Only [`Approve::Never`] needs one, and this is why: mentra resolves the
1033/// runtime gate's `Prompt` against the conversation's remembered rules *before*
1034/// the [`approver`] above is consulted, so a durable Global- or Project-scope
1035/// allow — seeded through the session's permission handle, and outliving both
1036/// the attach-time clear (session scope only) and every later attach — answers
1037/// ahead of [`DenyAll`] and lets a task recorded to refuse write anyway.
1038/// [`DenyAllGate`] states the refusal where mentra treats it as final. The two
1039/// say the same thing in the same words; only the layer differs.
1040///
1041/// **[`Always`](Approve::Always) and [`Prompt`](Approve::Prompt) install
1042/// nothing**, deliberately. Both permit consequential work, so a standing allow
1043/// is a host saying yes in advance rather than an override of anything — and
1044/// installing an authorizer *replaces* whatever the runtime carries rather than
1045/// layering over it, which for those two would cost a bound or a posture the
1046/// runtime had for no gain. A refusal cannot cost either: it answers from the
1047/// request alone and awaits nobody, so `Prompt`'s wait on
1048/// [`PromptHost`](crate::approve::PromptHost) is untouched and nothing here
1049/// gains something new to wait on.
1050///
1051/// Read once, before the first turn, unlike [`approver`] — which is rebuilt per
1052/// turn because whether `Prompt` is *answerable* is a property of the attaching
1053/// process (ADR-0019). The mode itself is the task's, recorded at spawn, and no
1054/// attach can change it; a task that must switch posture is a different task.
1055fn gated(run: PreparedRun, mode: Approve) -> PreparedRun {
1056    match mode {
1057        Approve::Never => run.with_tool_authorizer(DenyAllGate),
1058        Approve::Always | Approve::Prompt => run,
1059    }
1060}
1061
1062pub(crate) fn earlier_deadline(left: Option<u64>, right: Option<u64>) -> Option<u64> {
1063    match (left, right) {
1064        (Some(left), Some(right)) => Some(left.min(right)),
1065        (Some(value), None) | (None, Some(value)) => Some(value),
1066        (None, None) => None,
1067    }
1068}
1069
1070/// How often a running turn looks for a cancel marker: often enough that a
1071/// cancel is felt well inside an A2A caller's grace period, rarely enough
1072/// that the check — one `stat` — costs nothing.
1073const CANCEL_POLL: Duration = Duration::from_millis(100);
1074
1075fn remaining_deadline(deadline_at: Option<u64>) -> Option<Duration> {
1076    deadline_at.map(|deadline| Duration::from_millis(deadline.saturating_sub(now_ms())))
1077}
1078
1079/// The executor's event sink: every event lands in `events.jsonl`, and — when
1080/// a shell is waiting on this process — on that terminal as it happens.
1081///
1082/// One serialization feeds both, because the journal's shape is already the
1083/// shape the renderer reads — the terminal borrows it and the journal takes
1084/// it, so an event is never copied to be shown. Both kinds of failure are
1085/// swallowed: observability never fails the run, and a closed stdout says
1086/// nobody is reading, not that the work should stop.
1087struct FileSink {
1088    log: Arc<Mutex<EventLog>>,
1089    ctx: DriveContext,
1090}
1091
1092impl EventSink for FileSink {
1093    fn emit(&mut self, event: Event) -> io::Result<()> {
1094        if let Ok(value) = serde_json::to_value(&event) {
1095            self.ctx.show(&value);
1096            if let Ok(mut log) = self.log.lock() {
1097                let _ = log.append(event);
1098            }
1099        }
1100        Ok(())
1101    }
1102}
1103
1104mod resume;
1105#[cfg(test)]
1106mod tests;