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, Event, EventSink, ModelSelector,
64    RunOutcome, RunSpec, Runtime, RuntimeBuilder, ShellAccess, TurnOptions, Workspace,
65    WorkspaceBuilder, provider,
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, PendingTerminal, TaskMeta, bounded_text,
80        cancel_requested, load_meta, now_ms, read_terminal, request_cancel, save_meta,
81        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 raw terminal payload, as `terminal.json` holds it — or, for
96    /// `wait_for_message`, the correlated reply or terminal-tagged payload
97    /// `message_payload_for_dispatch` resolved.
98    Terminal(Value),
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/// **Threading (G7, `ca9ddcb`, applied at this crate's own boundary):** this
118/// function itself never touches a lock or a file — [`poll_once`] and
119/// [`is_attached`] do that, each on its own `spawn_blocking` thread, so the
120/// `time::sleep` between iterations is the only thing this `async fn` ever
121/// awaits directly on the caller's executor.
122pub(crate) async fn wait_for_terminal(
123    data: &DataDir,
124    task: &str,
125    timeout: Duration,
126    ctx: &DriveContext,
127) -> Result<WaitOutcome, String> {
128    let deadline = Instant::now().checked_add(timeout);
129    loop {
130        if let Some(terminal) = poll_once(data.clone(), task.to_string(), ctx.clone()).await? {
131            return Ok(WaitOutcome::Terminal(terminal));
132        }
133        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
134            return Ok(WaitOutcome::TimedOut {
135                attached: is_attached(data, task).await?,
136            });
137        }
138        time::sleep(POLL).await;
139    }
140}
141
142/// One [`wait_for_terminal`] iteration, entirely on a blocking thread: the
143/// terminal read, the non-blocking attach probe, and — if this call wins the
144/// attach — driving the task via a runtime [`Handle`](tokio::runtime::Handle)
145/// borrowed for exactly that. `drive`'s own `.await`s (the model turns) still
146/// run correctly under this: `Handle::block_on` drives them to completion on
147/// this same blocking-pool thread rather than a tokio worker thread, which is
148/// the whole point — nothing this reaches (`resolve`, `read_terminal`,
149/// `try_attach`, every lock and fs read `drive`'s own call tree makes,
150/// `settle_children`'s recursive `drive` calls included) ever runs on one.
151///
152/// `None` means the iteration made no progress (nothing to attach to yet, or
153/// [`drive`] itself backed off) — indistinguishable to the caller from "still
154/// running", which is exactly right: both just mean try again next poll.
155async fn poll_once(
156    data: DataDir,
157    task: String,
158    ctx: DriveContext,
159) -> Result<Option<Value>, String> {
160    let handle = tokio::runtime::Handle::current();
161    tokio::task::spawn_blocking(move || -> Result<Option<Value>, String> {
162        let paths = resolve(&data, &task)?;
163        if let Some(terminal) = read_terminal(&paths)? {
164            return Ok(Some(terminal));
165        }
166        match try_attach(&paths)? {
167            Some(guard) => handle.block_on(drive(&data, &task, guard, &ctx)),
168            None => Ok(None),
169        }
170    })
171    .await
172    .unwrap_or_else(|error| Err(format!("poll task: {error}")))
173}
174
175/// Whether a live executor currently holds `task`'s attach lock — the one
176/// lock probe [`wait_for_terminal`] and [`wait_for_message`] need outside
177/// their own poll loop, to answer a timeout's `attached` field. On a
178/// blocking thread, like every other lock touch in this module.
179async fn is_attached(data: &DataDir, task: &str) -> Result<bool, String> {
180    let data = data.clone();
181    let task = task.to_string();
182    tokio::task::spawn_blocking(move || -> Result<bool, String> {
183        let paths = resolve(&data, &task)?;
184        Ok(lock::is_held(&paths.attach_lock()))
185    })
186    .await
187    .unwrap_or_else(|error| Err(format!("check attach lock: {error}")))
188}
189
190/// Waits for one correlated message reply, attaching to produce it whenever
191/// the lock is free. Returns the dispatch payload (reply, or terminal tagged
192/// with the message id).
193///
194/// Nothing is shown while it drives. The caller asked for *one message's*
195/// reply, and the turns this process may have to run to reach it can belong
196/// to other messages entirely — streaming them would answer a question nobody
197/// asked, on the stream the answer is supposed to arrive on. `Approve::Prompt`
198/// still answers through `prompt_host`, because approval and visibility are
199/// independent facts.
200///
201/// `timeout` is saturated into a deadline, as [`wait_for_terminal`]'s is: a
202/// duration too large to represent as a deadline waits forever rather than
203/// panicking or refusing.
204///
205/// **Threading:** as [`wait_for_terminal`] — [`poll_message_once`] carries
206/// every lock and fs touch this makes onto a blocking thread; this `async fn`
207/// only ever awaits that and, between iterations, `time::sleep`.
208pub(crate) async fn wait_for_message(
209    data: &DataDir,
210    task: &str,
211    message_id: &str,
212    timeout: Duration,
213    prompt_host: Option<Arc<dyn crate::approve::PromptHost>>,
214) -> Result<WaitOutcome, String> {
215    let deadline = Instant::now().checked_add(timeout);
216    let ctx = DriveContext::new(None, prompt_host);
217    loop {
218        match poll_message_once(
219            data.clone(),
220            task.to_string(),
221            message_id.to_string(),
222            ctx.clone(),
223        )
224        .await?
225        {
226            MessagePoll::Resolved(payload) => return Ok(WaitOutcome::Terminal(payload)),
227            // A turn ran (for this message or another) but did not resolve
228            // ours: recheck immediately, the way the pre-thread-split loop
229            // did with its own `continue` — no reason to sleep when there is
230            // fresh state to read.
231            MessagePoll::Drove => continue,
232            MessagePoll::Idle => {}
233        }
234        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
235            return Ok(WaitOutcome::TimedOut {
236                attached: is_attached(data, task).await?,
237            });
238        }
239        time::sleep(POLL).await;
240    }
241}
242
243/// What one [`wait_for_message`] iteration found.
244enum MessagePoll {
245    /// The message's dispatch payload — a reply, or a terminal record tagged
246    /// with the message id.
247    Resolved(Value),
248    /// This iteration drove a turn (this task's, via [`drive`]) but it was
249    /// not the one the caller is waiting on; state may have changed, so the
250    /// next iteration should look again before waiting out the poll cadence.
251    Drove,
252    /// Nothing to read and nothing to attach to (or attaching was
253    /// contended); the ordinary "still waiting" case.
254    Idle,
255}
256
257/// One [`wait_for_message`] iteration, entirely on a blocking thread — see
258/// [`poll_once`], which this is the message-scoped twin of: it checks
259/// `message_id`'s own dispatch payload rather than only the task's terminal,
260/// and only attaches while the task itself has no terminal yet.
261async fn poll_message_once(
262    data: DataDir,
263    task: String,
264    message_id: String,
265    ctx: DriveContext,
266) -> Result<MessagePoll, String> {
267    let handle = tokio::runtime::Handle::current();
268    tokio::task::spawn_blocking(move || -> Result<MessagePoll, String> {
269        let paths = resolve(&data, &task)?;
270        let messages = inbox::load(&paths)?;
271        let terminal = read_terminal(&paths)?;
272        if let Some(payload) =
273            inbox::message_payload_for_dispatch(&task, &messages, &message_id, terminal.as_ref())?
274        {
275            return Ok(MessagePoll::Resolved(payload));
276        }
277        if terminal.is_none()
278            && let Some(guard) = try_attach(&paths)?
279        {
280            let _ = handle.block_on(drive(&data, &task, guard, &ctx))?;
281            return Ok(MessagePoll::Drove);
282        }
283        Ok(MessagePoll::Idle)
284    })
285    .await
286    .unwrap_or_else(|error| Err(format!("poll message: {error}")))
287}
288
289pub(crate) fn resolve(data: &DataDir, task: &str) -> Result<AgentPaths, String> {
290    data.agent_dir(task)
291        .filter(AgentPaths::exists)
292        .ok_or_else(|| format!("no task directory for {task}"))
293}
294
295pub(crate) fn try_attach(paths: &AgentPaths) -> Result<Option<lock::Lock>, String> {
296    lock::try_exclusive(&paths.attach_lock())
297        .map_err(|error| format!("acquire task attach lock: {error}"))
298}
299
300/// Requests downward cancellation: markers for the target and every attached
301/// (non-detached, non-terminal) descendant, honored at each executor's next
302/// turn boundary — or at the next attach for an agent nobody holds.
303pub(crate) fn cancel_tree(data: &DataDir, task: &str) -> Result<(), String> {
304    let mut queue = vec![task.to_string()];
305    let mut visited = 0_usize;
306    while let Some(current) = queue.pop() {
307        visited += 1;
308        if visited > MAX_TASKS {
309            break;
310        }
311        let Some(paths) = data.agent_dir(&current).filter(AgentPaths::exists) else {
312            continue;
313        };
314        if read_terminal(&paths)?.is_some() {
315            continue;
316        }
317        if !cancel_requested(&paths) {
318            request_cancel(&paths, Some(task))?;
319        }
320        queue.extend(children_of(data, &current)?);
321    }
322    Ok(())
323}
324
325/// The attached (non-detached) children of `task`, terminal or not.
326fn children_of(data: &DataDir, task: &str) -> Result<Vec<String>, String> {
327    let Some((key, _)) = valid_task_handle(task) else {
328        return Ok(Vec::new());
329    };
330    let agents = data.agents_dir(key);
331    let entries = match std::fs::read_dir(&agents) {
332        Ok(entries) => entries,
333        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
334        Err(error) => return Err(format!("scan workspace agents: {error}")),
335    };
336    let mut children = Vec::new();
337    for entry in entries {
338        let entry = entry.map_err(|error| format!("scan workspace agents: {error}"))?;
339        let id = entry.file_name().to_string_lossy().into_owned();
340        let handle = format!("{key}/{id}");
341        let Some(paths) = data.agent_dir(&handle) else {
342            continue;
343        };
344        let Ok(meta) = load_meta(&paths) else {
345            continue;
346        };
347        if !meta.detached && meta.parent.as_deref() == Some(task) {
348            children.push(handle);
349        }
350    }
351    Ok(children)
352}
353
354/// Executes the agent to its terminal record while holding the attach lock,
355/// and returns the raw terminal payload — or `None` when this attempt made
356/// no progress because the conversation it would resume is already claimed by
357/// another task's executor (see [`try_conversation`]). `None` means exactly
358/// what a contended [`try_attach`] already means to its own caller: nobody
359/// drove anything this attempt, the lock this call did hold (this task's own
360/// attach lock, dropped with `guard` on any return) is released, and the
361/// caller's poll loop retries — the same observe-don't-race contract, one
362/// layer down, for T2(b)'s double-continuation race.
363pub(crate) async fn drive(
364    data: &DataDir,
365    task: &str,
366    mut guard: lock::Lock,
367    ctx: &DriveContext,
368) -> Result<Option<Value>, String> {
369    let paths = resolve(data, task)?;
370    // Someone may have finished the task between our probe and our lock.
371    if let Some(terminal) = read_terminal(&paths)? {
372        return Ok(Some(terminal));
373    }
374    guard.write_fingerprint();
375    let mut meta = load_meta(&paths)?;
376    if meta.pending_terminal.is_none() {
377        match existing_conversation(&meta) {
378            Some(agent_id) => {
379                let (key, _) = valid_task_handle(task)
380                    .ok_or_else(|| format!("malformed task handle {task}"))?;
381                match try_conversation(data, key, &agent_id)? {
382                    Some(_conversation) => {
383                        run_model(data, task, &paths, &mut meta, ctx).await?;
384                    }
385                    None => return Ok(None),
386                }
387            }
388            None => run_model(data, task, &paths, &mut meta, ctx).await?,
389        }
390    }
391    Ok(Some(settle(data, &paths, &mut meta, ctx).await?))
392}
393
394/// The conversation this task's next turn resumes, if it resumes one at all
395/// — its own prior attach, or what it was minted to continue. `None` is a
396/// brand-new conversation, the only case [`try_conversation`] is skipped for:
397/// nothing else can already be driving a conversation that does not exist
398/// yet. The same two-branch read [`run_model`] makes of `reattached`, kept in
399/// one place so `drive`'s pre-check and `run_model`'s own resume agree by
400/// construction.
401fn existing_conversation(meta: &TaskMeta) -> Option<String> {
402    if meta.agent_id.is_empty() {
403        meta.continues.clone()
404    } else {
405        Some(meta.agent_id.clone())
406    }
407}
408
409/// Tries one conversation's lock, non-blocking — the conversation-scoped
410/// counterpart of [`try_attach`]. Two tasks that both record `continues`
411/// against the same agent id (T2's double-continuation race) — or, on a
412/// reattach, a second process holding a stale idea of this same task — must
413/// not both call `Workspace::resume` on it at once; `None` here means
414/// somebody already is, and the caller's contract is to observe that, not
415/// race it.
416fn try_conversation(
417    data: &DataDir,
418    key: &str,
419    agent_id: &str,
420) -> Result<Option<lock::Lock>, String> {
421    let path = data
422        .conversation_lock(key, agent_id)
423        .map_err(|error| format!("prepare conversation lock: {error}"))?;
424    lock::try_exclusive(&path).map_err(|error| format!("acquire conversation lock: {error}"))
425}
426
427/// Runs the recorded work to a pending completion. Model, configuration, and
428/// workspace failures become `Failed` completions; only metadata-persistence
429/// failures propagate as errors, leaving the agent resumable.
430async fn run_model(
431    data: &DataDir,
432    task: &str,
433    paths: &AgentPaths,
434    meta: &mut TaskMeta,
435    ctx: &DriveContext,
436) -> Result<(), String> {
437    if meta.deadline_passed() {
438        return record_pending(
439            paths,
440            meta,
441            PendingTerminal::Failed {
442                error: "task deadline elapsed before the next turn".to_string(),
443            },
444            Some(Bound::Deadline),
445        );
446    }
447    // A cancel before any turn — on a never-attached or between-attaches
448    // agent — settles without opening a workspace or touching the model.
449    if cancel_requested(paths) {
450        return record_pending(paths, meta, PendingTerminal::Cancelled, None);
451    }
452    inbox::revert_in_flight(paths)?;
453
454    let events = match EventLog::open(paths) {
455        Ok(log) => Arc::new(Mutex::new(log)),
456        Err(error) => {
457            return record_failure(
458                paths,
459                meta,
460                format!("open task event journal: {error}"),
461                None,
462            );
463        }
464    };
465    // Ahead of the run config: the first unusable option is what the task
466    // fails with, and `--provider` was read before `--effort` while both
467    // halves of the options lived in one config.
468    let runtime = match task_runtime(data, task, meta) {
469        Ok(runtime) => runtime,
470        Err(error) => return record_failure(paths, meta, error, None),
471    };
472    let (builder, spec) = run_parts(meta);
473    let workspace = match builder.with_runtime_builder(runtime).open().await {
474        Ok(workspace) => Arc::new(workspace),
475        Err(error) => return record_failure(paths, meta, error.to_string(), None),
476    };
477    // The run carries the workspace through the whole turn loop below, not
478    // just the mint: the workspace's hook registration and MCP connections
479    // end when it drops, and a task's `.basis/hooks.json` must keep its say over
480    // every turn (see `PreparedRun::with_workspace`).
481    //
482    // Three ways to open the conversation, and only the first is a *re*-open:
483    // a task that has attached before picks its own agent back up, a task
484    // minted with `--continue`/`--session` picks up the one it was told to
485    // continue, and everything else starts a new one. The middle case is a
486    // resume to mentra and a first attach to basis — its prompt has not been
487    // asked yet, which is exactly what `answered_before` below preserves.
488    let reattached = !meta.agent_id.is_empty();
489    let existing = existing_conversation(meta);
490    let prepared = match existing.as_deref() {
491        Some(agent_id) => workspace.resume(agent_id, spec),
492        None => workspace.prepare(spec),
493    };
494    let mut run = match prepared {
495        Ok(run) => run.with_workspace(workspace),
496        Err(error) => return record_failure(paths, meta, error.to_string(), None),
497    };
498    if !reattached {
499        meta.agent_id = run.agent_id().to_string();
500        meta.answered_before = run.answered_turns();
501        meta.updated_ms = now_ms();
502        save_meta(paths, meta)?;
503    }
504
505    // Resume recovery: an assistant turn committed *past what this task
506    // inherited* means the recorded prompt was already answered, and
507    // re-executing it would duplicate the conversation. The last committed
508    // assistant text stands in for the crashed process's unrecorded result.
509    let mut initial_done = false;
510    let mut last_result = String::new();
511    let mut last_stopped_by: Option<Bound> = None;
512    if reattached {
513        initial_done = run.answered_turns() > meta.answered_before;
514        if let Some(message) = run
515            .history()
516            .iter()
517            .rev()
518            .find(|message| matches!(message.role, mentra::Role::Assistant))
519        {
520            last_result = message.text();
521        }
522    }
523
524    let cancellation = CancellationToken::default();
525    loop {
526        // The turn boundary: cancel markers and deadlines are honored here.
527        if cancel_requested(paths) {
528            return record_pending(paths, meta, PendingTerminal::Cancelled, None);
529        }
530        let remaining = remaining_deadline(meta.deadline_at_ms);
531        if remaining.as_ref().is_some_and(Duration::is_zero) {
532            return record_pending(
533                paths,
534                meta,
535                PendingTerminal::Failed {
536                    error: "task deadline elapsed before the next turn".to_string(),
537                },
538                Some(Bound::Deadline),
539            );
540        }
541        let message = if initial_done {
542            inbox::start_next(paths)?
543        } else {
544            None
545        };
546        if initial_done && message.is_none() {
547            let (result, truncated) = bounded_text(last_result, MAX_RESULT_BYTES);
548            meta.result_truncated = truncated;
549            return record_pending(
550                paths,
551                meta,
552                PendingTerminal::Succeeded { result },
553                last_stopped_by,
554            );
555        }
556
557        let mut turn = TurnOptions::default().with_cancel(cancellation.clone());
558        if let Some(remaining) = remaining {
559            turn = turn.with_deadline(remaining);
560        }
561        let approver = match approver(meta.options.approve, ctx) {
562            Ok(approver) => approver,
563            Err(error) => return record_failure(paths, meta, error.to_string(), None),
564        };
565        let sink = FileSink {
566            log: Arc::clone(&events),
567            ctx: ctx.clone(),
568        };
569        let completed_message = message.as_ref().map(|(id, _)| id.clone());
570        let execution = async {
571            match message {
572                Some((_, body)) => run.send_with_options(body, sink, approver, turn).await,
573                None => {
574                    run.execute_with_approver_and_options(sink, approver, turn)
575                        .await
576                }
577            }
578        };
579        let report = match remaining {
580            Some(remaining) => match time::timeout(remaining, execution).await {
581                Ok(report) => report,
582                Err(_) => {
583                    cancellation.cancel();
584                    return record_pending(
585                        paths,
586                        meta,
587                        PendingTerminal::Failed {
588                            error: "task deadline elapsed during the turn".to_string(),
589                        },
590                        Some(Bound::Deadline),
591                    );
592                }
593            },
594            None => execution.await,
595        };
596        let report = match report {
597            Ok(report) => report,
598            Err(error) => return record_failure(paths, meta, error.to_string(), None),
599        };
600        // Banked per turn, not per attach: a task settles under one terminal
601        // record but its turns may be driven by several processes, and a
602        // crash between two of them must not un-spend what the first one did.
603        meta.usage = meta.usage.plus(report.usage);
604        meta.updated_ms = now_ms();
605        save_meta(paths, meta)?;
606
607        let stopped_by = report.stopped_by;
608        match report.outcome {
609            RunOutcome::Error { message } => {
610                return if cancel_requested(paths) {
611                    record_pending(paths, meta, PendingTerminal::Cancelled, None)
612                } else {
613                    record_failure(paths, meta, message, stopped_by)
614                };
615            }
616            RunOutcome::Ok => {
617                let result = report.final_message.unwrap_or_default();
618                if let Some(id) = completed_message {
619                    let (reply, result_truncated) = bounded_text(result.clone(), MAX_RESULT_BYTES);
620                    inbox::finish(
621                        paths,
622                        &id,
623                        Some(MessageReply {
624                            result: reply,
625                            result_truncated,
626                            stopped_by,
627                        }),
628                    )?;
629                }
630                initial_done = true;
631                last_result = result;
632                last_stopped_by = stopped_by;
633            }
634            // An outcome this build does not know — the enum is
635            // `#[non_exhaustive]` — is recorded as the failure it is rather
636            // than guessed into a success.
637            outcome => {
638                return record_failure(
639                    paths,
640                    meta,
641                    format!("unrecognized run outcome: {outcome:?}"),
642                    stopped_by,
643                );
644            }
645        }
646    }
647}
648
649fn record_pending(
650    paths: &AgentPaths,
651    meta: &mut TaskMeta,
652    completion: PendingTerminal,
653    stopped_by: Option<Bound>,
654) -> Result<(), String> {
655    if matches!(completion, PendingTerminal::Cancelled) {
656        meta.result_truncated = false;
657        meta.stopped_by = None;
658    } else {
659        meta.stopped_by = stopped_by;
660    }
661    meta.pending_terminal = Some(completion);
662    meta.updated_ms = now_ms();
663    save_meta(paths, meta)
664}
665
666fn record_failure(
667    paths: &AgentPaths,
668    meta: &mut TaskMeta,
669    message: String,
670    stopped_by: Option<Bound>,
671) -> Result<(), String> {
672    let (error, _) = bounded_text(message, MAX_RESULT_BYTES);
673    meta.result_truncated = false;
674    record_pending(paths, meta, PendingTerminal::Failed { error }, stopped_by)
675}
676
677/// The settle pass: parent scope as one ordering constraint, then two writes
678/// under one hold of the inbox lock — the unanswered sweep, then the
679/// terminal record, in that order and no other. A concurrent enqueue either
680/// lands before the sweep or is refused by the terminal record it would
681/// otherwise miss; a crash between the two writes leaves the sweep durable
682/// and no terminal record, so the task is still resumable and the next
683/// attach's `meta.pending_terminal` sends it straight back here — see
684/// [`inbox::finish_unanswered_durably`] for why the order is the other way
685/// round from how it reads.
686async fn settle(
687    data: &DataDir,
688    paths: &AgentPaths,
689    meta: &mut TaskMeta,
690    ctx: &DriveContext,
691) -> Result<Value, String> {
692    reconsider_cancel(paths, meta)?;
693    let cancel_children = !matches!(
694        meta.pending_terminal,
695        Some(PendingTerminal::Succeeded { .. })
696    );
697    settle_children(data, meta, cancel_children, ctx).await?;
698    // A cancel that arrived while children settled still lands before the
699    // terminal record, exactly as the daemon replaced a pending completion.
700    reconsider_cancel(paths, meta)?;
701
702    let payload = meta
703        .terminal_payload()
704        .expect("a completion was recorded before settling");
705    let _inbox_lock = inbox::finish_unanswered_durably(paths)?;
706    write_terminal(paths, &payload)?;
707    Ok(payload)
708}
709
710fn reconsider_cancel(paths: &AgentPaths, meta: &mut TaskMeta) -> Result<(), String> {
711    if cancel_requested(paths) && !matches!(meta.pending_terminal, Some(PendingTerminal::Cancelled))
712    {
713        record_pending(paths, meta, PendingTerminal::Cancelled, None)?;
714    }
715    Ok(())
716}
717
718/// Blocks until every attached child holds a terminal record. Children whose
719/// locks are free are driven here — the attached process is the supervisor of
720/// its own subtree; children with live executors are observed. A failing or
721/// cancelled parent cancels its children first; a parent past its own
722/// deadline stops waiting politely and cancels too (its children's deadlines
723/// can only be narrower, so this converges).
724async fn settle_children(
725    data: &DataDir,
726    meta: &TaskMeta,
727    cancel_children: bool,
728    ctx: &DriveContext,
729) -> Result<(), String> {
730    loop {
731        let mut unfinished = Vec::new();
732        for child in children_of(data, &meta.id)? {
733            let Some(paths) = data.agent_dir(&child).filter(AgentPaths::exists) else {
734                continue;
735            };
736            if read_terminal(&paths)?.is_none() {
737                unfinished.push((child, paths));
738            }
739        }
740        if unfinished.is_empty() {
741            return Ok(());
742        }
743        let cancel = cancel_children || meta.deadline_passed();
744        let mut remaining = false;
745        for (child, paths) in unfinished {
746            if cancel && !cancel_requested(&paths) {
747                request_cancel(&paths, Some(&meta.id))?;
748            }
749            match try_attach(&paths)? {
750                Some(guard) => {
751                    // A child driven here is somebody else's run: this
752                    // process is finishing it to keep the scope rule, not
753                    // showing it to whoever asked about the parent. `None`
754                    // (its conversation is claimed elsewhere) leaves it
755                    // unfinished for the next pass, same as a contended
756                    // attach lock.
757                    if Box::pin(drive(data, &child, guard, &ctx.hidden()))
758                        .await?
759                        .is_none()
760                    {
761                        remaining = true;
762                    }
763                }
764                None => remaining = true,
765            }
766        }
767        if remaining {
768            time::sleep(POLL).await;
769        }
770    }
771}
772
773/// The per-workspace and per-run halves of the recorded options.
774///
775/// The provider and the base URL are the other half — process facts since
776/// ADR-0018 — and are stated on [`task_runtime`]'s recipe instead. Saying them
777/// here as well would build a value that
778/// [`with_runtime_builder`](basis::WorkspaceBuilder::with_runtime_builder)
779/// then replaces.
780fn run_parts(meta: &TaskMeta) -> (WorkspaceBuilder, RunSpec) {
781    let options = &meta.options;
782    let mut builder = Workspace::builder(Path::new(&meta.workspace))
783        .with_shell(ShellAccess::from_flag(!options.no_shell));
784    if let Some(model) = &options.model {
785        builder = builder.with_model(ModelSelector::Id(model.clone()));
786    }
787    // Recorded as the type it is; `load_meta` has already folded the
788    // pre-0.6 two-string spelling into this one field.
789    if let Some(system_prompt) = options.system_prompt.clone() {
790        builder = builder.with_system_prompt(system_prompt);
791    }
792
793    let mut spec = RunSpec::new(meta.prompt.clone());
794    if let Some(effort) = options.effort {
795        spec = spec.with_effort(effort);
796    }
797    if let Some(remaining) = remaining_deadline(meta.deadline_at_ms) {
798        spec = spec.with_deadline(remaining.max(Duration::from_millis(1)));
799    }
800    if let Some(tool_budget) = options.tool_budget {
801        spec = spec.with_tool_budget(tool_budget);
802    }
803    if let Some(token_budget) = options.token_budget {
804        spec = spec.with_token_budget(token_budget);
805    }
806    (builder, spec)
807}
808
809/// The recipe for this task's own runtime: the process half of the recorded
810/// options, plus the identity a spawned command needs to find the same data
811/// directory and name its own children.
812///
813/// One runtime per task (ADR-0018): the environment below names *this* task,
814/// and a runtime's command environment is fixed for every workspace on it, so
815/// two concurrent tasks sharing one runtime would tell their subprocesses the
816/// same task id. The store lands under the workspace's key, which is what
817/// lets any later process resume any agent.
818fn task_runtime(data: &DataDir, task: &str, meta: &TaskMeta) -> Result<RuntimeBuilder, String> {
819    let (key, _) =
820        valid_task_handle(task).ok_or_else(|| format!("malformed task handle {task}"))?;
821    let mut runtime = Runtime::builder()
822        .with_store_dir(data.store_dir(key))
823        .with_command_environment(crate::BASIS_TASK_ID, task)
824        .with_command_environment(crate::BASIS_DATA_DIR, data.root().to_string_lossy());
825    if let Some(name) = &meta.options.provider {
826        runtime = runtime.with_provider(provider::parse(name).map_err(|error| error.to_string())?);
827    }
828    if let Some(base_url) = &meta.options.base_url {
829        runtime = runtime.with_base_url(base_url);
830    }
831    if let Some(parent) = &meta.parent {
832        runtime = runtime.with_command_environment(crate::BASIS_PARENT_TASK_ID, parent);
833    }
834    Ok(runtime)
835}
836
837/// `mode`'s approver for one attach, given what this process brought to it.
838///
839/// Under ADR-0019 the executor is whichever process holds the attach lock, so
840/// whether `Prompt` is answerable is a property of the attacher rather than
841/// of the task — see [`PromptHost`](crate::approve::PromptHost).
842fn approver(mode: Approve, ctx: &DriveContext) -> Result<Box<dyn Approver>, Error> {
843    crate::approve::validate_approval(mode, ctx.can_ask())?;
844    Ok(match mode {
845        Approve::Always => Box::new(AllowAll),
846        Approve::Never => Box::new(DenyAll),
847        // `ctx.can_ask()` having just returned true is what makes this
848        // `expect` honest: `validate` above already refused `Prompt` for a
849        // context with no host, or one that cannot currently ask.
850        Approve::Prompt => ctx
851            .approver()
852            .expect("validate confirmed a host that can ask"),
853    })
854}
855
856pub(crate) fn earlier_deadline(left: Option<u64>, right: Option<u64>) -> Option<u64> {
857    match (left, right) {
858        (Some(left), Some(right)) => Some(left.min(right)),
859        (Some(value), None) | (None, Some(value)) => Some(value),
860        (None, None) => None,
861    }
862}
863
864fn remaining_deadline(deadline_at: Option<u64>) -> Option<Duration> {
865    deadline_at.map(|deadline| Duration::from_millis(deadline.saturating_sub(now_ms())))
866}
867
868/// The executor's event sink: every event lands in `events.jsonl`, and — when
869/// a shell is waiting on this process — on that terminal as it happens.
870///
871/// One serialization feeds both, because the journal's shape is already the
872/// shape the renderer reads — the terminal borrows it and the journal takes
873/// it, so an event is never copied to be shown. Both kinds of failure are
874/// swallowed: observability never fails the run, and a closed stdout says
875/// nobody is reading, not that the work should stop.
876struct FileSink {
877    log: Arc<Mutex<EventLog>>,
878    ctx: DriveContext,
879}
880
881impl EventSink for FileSink {
882    fn emit(&mut self, event: Event) -> io::Result<()> {
883        if let Ok(value) = serde_json::to_value(event) {
884            self.ctx.show(&value);
885            if let Ok(mut log) = self.log.lock() {
886                let _ = log.append(value);
887            }
888        }
889        Ok(())
890    }
891}
892
893#[cfg(test)]
894mod tests;