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