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