Skip to main content

car_server_core/
mcp_assistant.rs

1//! The flagship assistant as three MCP tools: `assistant_start`,
2//! `assistant_poll`, `assistant_cancel` (car#972 §6).
3//!
4//! ## Why a run handle and not a blocking call
5//!
6//! An assistant run takes minutes. A blocking `tools/call` times out in most
7//! hosts, and the host has no way to say "stop" while it waits. So `start`
8//! returns a handle immediately, `poll` reads incremental progress from a
9//! buffer, and `cancel` targets the handle.
10//!
11//! A run handle is independent of the transport's small protocol-session
12//! record: the `run_id` is application state the **client** carries between
13//! calls, exactly like the opaque `resources/list` cursor. Progress
14//! *notifications* would require a server→client sink the endpoint still does
15//! not have — that is why this is a poll.
16//!
17//! ## Not a second envelope
18//!
19//! A poll returns the `car.do/1` events and, at the end, the `car.do/1`
20//! document — built by [`car_server_core::assistant::do_json`], the same code
21//! `car do --json` writes, including its `SUMMARY_CAP` / `RECEIPT_SAMPLE`
22//! truncation. That truncation exists because a delegating caller pays for
23//! every byte in the user's context, which is true of an MCP tool result
24//! verbatim. The only field this layer adds is a monotonic `seq` per event.
25//!
26//! [`car_server_core::assistant::do_json`]: crate::assistant::do_json
27//!
28//! ## Daemon only
29//!
30//! These are registered on the daemon's HTTP endpoint through
31//! [`car_mcp::Server::register_tool`] and nowhere else. `car-mcp-server` (the
32//! stdio binary an editor plugin launches) is `car-mcp` + `car-telemetry`: it
33//! has no `Runtime`, no inference engine, and no daemon state, and giving it
34//! one would mean shipping the whole daemon inside the plugin's MCP binary. A
35//! stdio client asking for `assistant_start` gets "unknown tool", which is
36//! accurate, and `car do --json` is the delegation path there.
37//!
38//! ## Lifetime, and what it is not
39//!
40//! The registry is in memory. A `run_id` is a handle on a **live run**, not a
41//! durable record: a daemon restart makes every handle unknown, and `poll`
42//! says so rather than returning an empty `running` forever. Three bounds keep
43//! it honest — [`MAX_OPEN_RUNS`] concurrent runs, [`RUN_IDLE_TTL_SECS`] since
44//! the last poll, and [`RUN_EVENT_BUFFER_MAX`] retained events with the head
45//! trimmed and the loss *stated* (`events_skipped`), never a silent gap.
46//!
47//! ## Recursion (car#972 §7)
48//!
49//! [`car_external_agents::recursion`]'s guard reads `$CAR_INVOKED_BY` from the
50//! process environment, which works when the host *spawns* CAR. The daemon is
51//! spawned by the supervisor and serves many callers, so a caller that is
52//! itself an adapter names itself per call with `invoked_by`. That id is merged
53//! with the daemon's own chain — read once at registry construction, never
54//! per request — and the result is **recorded on the run and echoed by
55//! `poll`**.
56//!
57//! Recorded, not enforced: nothing on this surface spawns an external agent, so
58//! there is no adapter for the guard to refuse here. The ancestry's job is to
59//! be the chain an agent *further down* is judged against, and to make a loop
60//! visible in a poll result. What closes the cycle on this surface is the
61//! posture below.
62//!
63//! The cycle §7 names — CAR → `shell` → `claude -p` → CAR — is closed here by
64//! the execution posture rather than by the ancestry: the default run is a
65//! sandbox with no network and no host binaries, and a `local: true` run binds
66//! at [`PermissionTier::ReadOnly`] with no approval gate, so `shell`,
67//! `write_file`, and `edit_file` are all refused. Like the guard itself, this
68//! is a cost-and-hang control that depends on the posture it describes; it is
69//! not a security boundary.
70//!
71//! [`PermissionTier::ReadOnly`]: car_policy::permission::PermissionTier
72
73use std::collections::{HashMap, VecDeque};
74use std::path::PathBuf;
75use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
76use std::sync::{Arc, Mutex as StdMutex};
77
78use car_external_agents::recursion::seed_ancestry_in;
79use car_inference::tasks::generate::Message;
80use car_inference::InferenceEngine;
81use car_mcp::{RegisterError, ToolError, ToolHandler};
82use car_policy::permission::PermissionTier;
83use serde_json::{json, Value};
84use tokio::sync::{OwnedSemaphorePermit, Semaphore};
85
86use crate::assistant::do_json::{
87    startup_error_doc, EventSink, GoalReport, JsonEmitter, SandboxPosture,
88};
89use crate::assistant::{
90    bind_default_substrate, build_assistant_runtime, prompt, run_assistant_goal_loop,
91    run_assistant_loop_cancellable, AssistantConfig, AssistantEvent, DEFAULT_ASSISTANT_IMAGE,
92};
93use crate::coder::native_loop::TurnGenerator;
94use crate::session::ServerState;
95
96/// Concurrently **executing** assistant runs per daemon. Each pins a
97/// `Runtime`, a substrate (usually a container), and a model session, so they
98/// are not free.
99///
100/// The same number `coder.discuss` uses, for the same reason and enforced the
101/// same way: a semaphore permit taken **before** any of `start`'s async work —
102/// not by counting the registry, which `discuss` records as a TOCTOU check
103/// that bounded nothing under pipelined starts.
104///
105/// The permit lives in the run's *task*, not in its registry entry, so it comes
106/// back the moment the run stops rather than when the record is finally reaped.
107/// A finished run still occupies the registry until it is polled and aged out —
108/// but it costs nothing, and a client that polled eight runs to completion must
109/// not have to wait out the idle TTL to start a ninth.
110pub const MAX_OPEN_RUNS: usize = 8;
111
112/// A run nobody has polled for this long is cancelled and dropped. Long enough
113/// that a host doing something else between polls is fine; short enough that an
114/// abandoned run does not bill model tokens to nobody all afternoon.
115pub const RUN_IDLE_TTL_SECS: u64 = 60 * 60;
116
117/// Retained events per run. Past this the oldest are dropped and `poll` reports
118/// `events_skipped` — a trimmed head is stated, never a silent gap. Same cap
119/// and same discipline as the discuss replay buffer.
120pub const RUN_EVENT_BUFFER_MAX: usize = 2000;
121
122/// Turn cap when the caller does not set one — the `car do --max-turns`
123/// default, because this is the same agent.
124const DEFAULT_MAX_TURNS: u32 = 50;
125
126/// Hard ceiling on a caller-supplied `max_turns`. The cap is a backstop against
127/// a runaway loop; letting a caller raise it without limit removes the backstop.
128const MAX_MAX_TURNS: u32 = 200;
129
130/// Goal-mode re-drive budget — the `car do --goal-max-iterations` default.
131const GOAL_MAX_ITERATIONS: u32 = 10;
132
133/// What `start` tells the caller to wait before its first `poll`.
134const POLL_AFTER_MS: u64 = 2000;
135
136/// How often the background reaper looks for idle runs.
137const REAP_INTERVAL_SECS: u64 = 60;
138
139/// The registry's bounds, so a test can drive the TTL and the buffer trim
140/// without mocking the clock or queueing 2000 real events.
141#[derive(Clone, Copy)]
142pub struct RunBounds {
143    pub max_open_runs: usize,
144    pub idle_ttl_secs: u64,
145    pub event_buffer_max: usize,
146}
147
148impl Default for RunBounds {
149    fn default() -> Self {
150        Self {
151            max_open_runs: MAX_OPEN_RUNS,
152            idle_ttl_secs: RUN_IDLE_TTL_SECS,
153            event_buffer_max: RUN_EVENT_BUFFER_MAX,
154        }
155    }
156}
157
158fn now_secs() -> u64 {
159    std::time::SystemTime::now()
160        .duration_since(std::time::UNIX_EPOCH)
161        .map(|d| d.as_secs())
162        .unwrap_or(0)
163}
164
165/// Take a `std` lock without letting a poisoned mutex become permanent — the
166/// same reasoning as `coder::discuss::lock`. A panic under one of these would
167/// otherwise wedge the run for its whole lifetime — and the detached run task
168/// swallows the panic itself, so nothing else would report it. What the caller
169/// sees if a run dies anyway is [`RunEntry::settle_if_task_died`]'s error
170/// document; this keeps one panic from poisoning the *next* run's bookkeeping.
171fn lock<T>(m: &StdMutex<T>) -> std::sync::MutexGuard<'_, T> {
172    m.lock().unwrap_or_else(|e| e.into_inner())
173}
174
175/// The state of a run **handle**, which is not the state of the run's work.
176///
177/// `Ok` means the loop reached a terminal outcome; whether that outcome was a
178/// success, a `max_turns` stop, or a goal that was never met is the `status`
179/// field of the `car.do/1` document, which `poll` returns alongside this.
180#[derive(Clone, Copy, PartialEq, Eq, Debug)]
181enum RunStatus {
182    Running,
183    Ok,
184    Error,
185    Cancelled,
186}
187
188impl RunStatus {
189    fn as_str(self) -> &'static str {
190        match self {
191            RunStatus::Running => "running",
192            RunStatus::Ok => "ok",
193            RunStatus::Error => "error",
194            RunStatus::Cancelled => "cancelled",
195        }
196    }
197
198    fn is_terminal(self) -> bool {
199        !matches!(self, RunStatus::Running)
200    }
201}
202
203/// The handle state and the terminal document behind ONE lock, so the
204/// terminal-state guard is a single critical section.
205///
206/// Adopted from `car_a2a::store`'s `update_state` guard, and for the reason
207/// A2A documents hitting: a run that has been cancelled may still be unwinding,
208/// and the task's own settle must not clobber `cancelled` back to `ok`.
209#[derive(Default)]
210struct RunOutcome {
211    status: Option<RunStatus>,
212    doc: Option<Value>,
213}
214
215/// The `car.do/1` event stream for one run, with a monotonic `seq` and a
216/// trimmed head.
217struct EventBuffer {
218    events: VecDeque<Value>,
219    /// Seq the next event will take.
220    next_seq: u64,
221    /// Seq of the oldest event still retained. Rises as the head is trimmed,
222    /// which is what makes `events_skipped` computable rather than guessed.
223    first_seq: u64,
224    max: usize,
225}
226
227impl EventBuffer {
228    fn new(max: usize) -> Self {
229        Self {
230            events: VecDeque::new(),
231            next_seq: 0,
232            first_seq: 0,
233            max,
234        }
235    }
236
237    fn push(&mut self, mut event: Value) {
238        let seq = self.next_seq;
239        self.next_seq += 1;
240        if let Some(obj) = event.as_object_mut() {
241            obj.insert("seq".to_string(), json!(seq));
242        }
243        self.events.push_back(event);
244        while self.events.len() > self.max {
245            self.events.pop_front();
246            self.first_seq += 1;
247        }
248    }
249
250    /// Events at or after `since_seq`, plus how many were dropped from the head
251    /// before the caller could read them.
252    fn since(&self, since_seq: u64) -> (Vec<Value>, u64) {
253        let events = self
254            .events
255            .iter()
256            .filter(|e| e["seq"].as_u64().unwrap_or(0) >= since_seq)
257            .cloned()
258            .collect();
259        (events, self.first_seq.saturating_sub(since_seq))
260    }
261}
262
263/// One live run.
264struct RunEntry {
265    id: String,
266    /// The invocation chain this run was started from — the process ancestry
267    /// plus whatever the caller named itself as. Echoed by `poll` so the
268    /// guard's answer travels with the run.
269    ancestry: Vec<String>,
270    created_at: u64,
271    /// Last `poll` (or the start), for the idle TTL.
272    last_poll: AtomicU64,
273    outcome: StdMutex<RunOutcome>,
274    events: StdMutex<EventBuffer>,
275    /// Checked by the loop at each turn boundary — so a cancel lands between
276    /// model calls, never inside one.
277    cancel: Arc<AtomicBool>,
278    /// The run's task, so the reaper can stop one parked in a model call. Not
279    /// an ownership cycle: a `JoinHandle` is a handle, and the runtime drops
280    /// the future (releasing its `Arc<RunEntry>` and its slot permit) when the
281    /// task ends.
282    task: StdMutex<Option<tokio::task::JoinHandle<()>>>,
283}
284
285impl RunEntry {
286    fn touch(&self) {
287        self.last_poll.store(now_secs(), Ordering::SeqCst);
288    }
289
290    fn idle_secs(&self) -> u64 {
291        now_secs().saturating_sub(self.last_poll.load(Ordering::SeqCst))
292    }
293
294    fn status(&self) -> RunStatus {
295        lock(&self.outcome).status.unwrap_or(RunStatus::Running)
296    }
297
298    /// Record the terminal state, unless one is already recorded.
299    ///
300    /// The guard is the point: a cancelled run whose task is still unwinding
301    /// will settle its own outcome a moment later, and that write must not
302    /// turn `cancelled` into `ok`.
303    fn settle(&self, status: RunStatus, doc: Option<Value>) {
304        let mut out = lock(&self.outcome);
305        if out.status.is_some() {
306            return;
307        }
308        out.status = Some(status);
309        out.doc = doc;
310    }
311
312    /// Settle a run whose task has ended without recording any outcome.
313    ///
314    /// The only way that happens is a panic. `tokio::spawn` swallows a panic
315    /// into the `JoinHandle`, nothing joins one, and `run_task` records its
316    /// outcome as its last statement — so a panicked run would otherwise report
317    /// `running` forever, *and* report it forever: every `poll` calls
318    /// [`Self::touch`], which keeps the idle reaper away from the entry for as
319    /// long as an attentive client keeps asking. The module promises `poll`
320    /// says what happened rather than returning an empty `running`; this is
321    /// what makes that true when the run dies instead of finishing.
322    ///
323    /// No race with the normal path: `is_finished()` only flips once the future
324    /// has completed, and on the normal path `settle` ran inside it.
325    fn settle_if_task_died(&self) {
326        if self.status().is_terminal() {
327            return;
328        }
329        if !lock(&self.task).as_ref().is_some_and(|h| h.is_finished()) {
330            return;
331        }
332        tracing::error!(
333            run_id = %self.id,
334            "assistant run task ended without an outcome; reporting it as an error"
335        );
336        self.settle(
337            RunStatus::Error,
338            Some(startup_error_doc(
339                "run_task_died",
340                "the run's task ended without producing a result, which means it panicked. \
341                 Nothing was left running; the events already returned are all there are.",
342                &[
343                    "Start again with assistant_start.",
344                    "Check the daemon log for the panic.",
345                ],
346            )),
347        );
348    }
349
350    /// Ask the run to stop. It stops at the next turn boundary, not now — the
351    /// loop checks the flag between model calls.
352    fn request_cancel(&self) {
353        self.cancel.store(true, Ordering::SeqCst);
354    }
355
356    /// Stop a run nobody is waiting for: request the cancel AND drop the task.
357    ///
358    /// Only the reaper does this. `assistant_cancel` deliberately does not: a
359    /// caller who cancels still wants the document describing what the run had
360    /// done, and the loop produces one (`status: "cancelled"`, with receipts)
361    /// if it is allowed to unwind. Nobody is going to read a reaped run's
362    /// document, so there the priority is to stop spending immediately.
363    fn abandon(&self) {
364        self.request_cancel();
365        self.settle(RunStatus::Cancelled, None);
366        if let Some(handle) = lock(&self.task).take() {
367            handle.abort();
368        }
369    }
370}
371
372/// Appends the emitter's `car.do/1` events into a run's buffer.
373///
374/// Holds a `Weak` so an already-reaped run's still-unwinding task drops its
375/// events instead of refilling a buffer nobody can reach.
376struct RunSink(std::sync::Weak<RunEntry>);
377
378impl EventSink for RunSink {
379    fn emit(&self, event: Value) {
380        if let Some(entry) = self.0.upgrade() {
381            lock(&entry.events).push(event);
382        }
383    }
384}
385
386/// The model seam: the engine that builds the runtime, and the generator the
387/// loop drives.
388///
389/// Two handles rather than one because they are the same object in production
390/// (`InferenceEngine` implements [`TurnGenerator`]) and different in tests,
391/// where a scripted generator answers turns while a real-but-unused engine
392/// supplies the runtime — the pattern `coder::discuss` tests use.
393#[derive(Clone)]
394struct ModelSeam {
395    engine: Arc<InferenceEngine>,
396    generator: Arc<dyn TurnGenerator>,
397}
398
399/// Fires the RUN-ENDED transition on this run's `browser.view.*` view when the
400/// run's task ends — including a reap that aborts it, since an aborted future
401/// is still dropped.
402///
403/// A `Drop` impl rather than a line at the end of the task body precisely
404/// because the abort path has no end of the body: without this, a reaped run
405/// would leave its drawer permanently showing an agent that is not there,
406/// with every control refusing input.
407///
408/// It does NOT unregister a view somebody is watching. The browser outlives
409/// its run by design — the strip disappears and the user drives, with the
410/// last page exactly as the agent left it.
411///
412/// It DOES release a view **nobody is subscribed to**
413/// (`BrowserViewRegistry::release_if_idle`). Replacement — "a new run for the
414/// same conversation" — is the documented lifetime bound, and it is
415/// unreachable on this path: the key is `mcp-run-<uuid>`, minted fresh per
416/// run, so no later run ever registers it again. Without the release, every
417/// assistant run that browsed left one idle Chromium registered for the
418/// daemon's entire lifetime. A view with no subscribers is showing nobody
419/// anything, so releasing it costs no observable behavior — and one a drawer
420/// IS watching is kept, which is the guarantee that matters.
421struct BrowserViewGuard {
422    /// The view itself, NOT its key. The signal fires asynchronously, so a
423    /// key re-resolved at fire time can land on a SUCCESSOR run's browser —
424    /// clearing its strip and re-opening input to everyone while that agent
425    /// is actively driving. Holding the `Arc` makes a late arrival for a
426    /// replaced view inert instead. `release_if_idle` is identity-checked for
427    /// the same reason.
428    view: Arc<crate::browser_view::BrowserView>,
429    /// Where to release it, when it turns out nobody is watching.
430    views: Arc<crate::browser_view::BrowserViewRegistry>,
431}
432
433impl Drop for BrowserViewGuard {
434    fn drop(&mut self) {
435        let view = Arc::clone(&self.view);
436        let views = Arc::clone(&self.views);
437        // `note_run_ended` is async (it drives the control reducer and
438        // publishes the resulting presentation to any open drawer), and
439        // `drop` is not. Only spawn when a runtime is actually there to spawn
440        // onto — a drop during daemon shutdown otherwise panics, and at that
441        // point every view is going away regardless.
442        if let Ok(handle) = tokio::runtime::Handle::try_current() {
443            handle.spawn(async move {
444                view.note_run_ended().await;
445                views.release_if_idle(&view).await;
446            });
447        }
448    }
449}
450
451/// Live assistant runs, keyed by `run_id`.
452pub struct AssistantRunRegistry {
453    state: Arc<ServerState>,
454    runs: tokio::sync::Mutex<HashMap<String, Arc<RunEntry>>>,
455    slots: Arc<Semaphore>,
456    bounds: RunBounds,
457    /// Test override. `None` means the daemon's shared engine, resolved on the
458    /// first `start` rather than at registration — initializing inference at
459    /// boot would spawn the offload worker for a daemon that may never be asked
460    /// to run anything.
461    model: Option<ModelSeam>,
462    /// Where execution traces go, or `None` for a test that must not write into
463    /// the user's real trajectory history. Same explicit `Option` and the same
464    /// reasoning as [`build_assistant_runtime`]'s own parameter.
465    trajectories: Option<PathBuf>,
466    /// The daemon's **own** invocation chain, read from `$CAR_INVOKED_BY` once
467    /// here rather than on every `start`.
468    ///
469    /// Once, because the daemon's environment is fixed when the supervisor
470    /// spawns it — re-reading it per request would make each run's ancestry a
471    /// function of process-global state that no caller controls, and would make
472    /// every test of this surface depend on the shell it was launched from.
473    /// (This repo ships `CAR_INVOKED_BY=claude-code` in `plugins/car/.mcp.json`,
474    /// so "the shell it was launched from" is not hypothetical.)
475    base_ancestry: Vec<String>,
476}
477
478impl AssistantRunRegistry {
479    /// The daemon's registry, with a background reaper.
480    pub fn new(state: Arc<ServerState>) -> Arc<Self> {
481        let registry = Arc::new(Self {
482            state,
483            runs: tokio::sync::Mutex::new(HashMap::new()),
484            slots: Arc::new(Semaphore::new(MAX_OPEN_RUNS)),
485            bounds: RunBounds::default(),
486            model: None,
487            trajectories: Some(car_memgine::TrajectoryStore::default_path()),
488            base_ancestry: car_external_agents::recursion::ancestry(),
489        });
490        // Weak, so the reaper is not what keeps the registry alive: when the
491        // daemon drops it, the next tick ends the task.
492        let weak = Arc::downgrade(&registry);
493        tokio::spawn(async move {
494            let mut ticker =
495                tokio::time::interval(std::time::Duration::from_secs(REAP_INTERVAL_SECS));
496            loop {
497                ticker.tick().await;
498                match weak.upgrade() {
499                    Some(registry) => registry.reap_idle().await,
500                    None => break,
501                }
502            }
503        });
504        registry
505    }
506
507    fn seam(&self) -> ModelSeam {
508        self.model.clone().unwrap_or_else(|| {
509            let engine = crate::handler::get_inference_engine(&self.state).clone();
510            ModelSeam {
511                generator: engine.clone(),
512                engine,
513            }
514        })
515    }
516
517    /// Cancel and drop runs nobody has polled inside the TTL — **including
518    /// runs still executing**, which is the point: an abandoned run is billing
519    /// model tokens to nobody.
520    pub(crate) async fn reap_idle(&self) {
521        let stale: Vec<Arc<RunEntry>> = {
522            let runs = self.runs.lock().await;
523            runs.values()
524                .filter(|e| e.idle_secs() > self.bounds.idle_ttl_secs)
525                .cloned()
526                .collect()
527        };
528        for entry in stale {
529            tracing::info!(
530                run_id = %entry.id,
531                idle_secs = entry.idle_secs(),
532                "reaping an assistant run nobody has polled"
533            );
534            entry.abandon();
535            self.runs.lock().await.remove(&entry.id);
536        }
537    }
538
539    /// `assistant_start` — bind an environment, spawn the run, return a handle.
540    pub async fn start(&self, args: &Value) -> Result<Value, ToolError> {
541        let mut req = StartArgs::parse(args)?;
542        // A caller-supplied root that does not exist is a run that would bind,
543        // start, and then fail every file and shell call for a reason the model
544        // cannot see. Checked before a slot is reserved, and reported as an
545        // execution error rather than a protocol one so the model reads it and
546        // can correct the path itself.
547        if !req.cwd.is_dir() {
548            return Err(refused(&format!(
549                "cwd is not a directory: {}. Pass the absolute path of the project the run \
550                 should work in.",
551                req.cwd.display()
552            )));
553        }
554
555        // Reap before enforcing the cap, so this morning's forgotten run never
556        // blocks this afternoon's.
557        self.reap_idle().await;
558        // RESERVE the slot before any async work below. Counting the registry
559        // and inserting afterwards is the TOCTOU `coder::discuss` records:
560        // pipelined starts all read the same count, all pass, and the cap
561        // bounds nothing.
562        let slot = self.slots.clone().try_acquire_owned().map_err(|_| {
563            refused(&format!(
564                "{} assistant runs are already executing, which is the limit. Wait for one \
565                 to reach a terminal status (assistant_poll) or stop one with \
566                 assistant_cancel, then start again — starts are refused, never queued.",
567                self.bounds.max_open_runs
568            ))
569        })?;
570
571        let env = bind_default_substrate(req.local, false, &req.cwd, None).await;
572        // Captured before `env` is consumed by `build_assistant_runtime`: the
573        // envelope has to report what the run was BOUND to, not what was asked
574        // for. A run that silently fell back to the local host is materially
575        // different from one that chose it.
576        let posture = SandboxPosture {
577            sandboxed: env.sandboxed,
578            image: env.sandboxed.then(|| DEFAULT_ASSISTANT_IMAGE.to_string()),
579            tier: format!("{:?}", env.tier),
580            root: env.root.display().to_string(),
581            mount: env.mount.as_ref().map(|m| m.path.display().to_string()),
582            fallback_notice: env.fallback_notice.clone(),
583        };
584        // A goal check is a `shell` call on the substrate. At ReadOnly — a
585        // `local: true` run, or a sandbox run that fell back because Docker is
586        // not there — shell is gated and this surface has no approval gate, so
587        // the check would be refused every iteration and the run would burn its
588        // whole budget failing a test it was never allowed to run. Refuse up
589        // front and say which it is.
590        if req.until.is_some() && matches!(env.tier, PermissionTier::ReadOnly) {
591            return Err(refused(&format!(
592                "`until` needs to run a shell command to decide completion, and this run \
593                 bound at ReadOnly ({}), where shell is refused. Drop `until`, or start \
594                 without `local` so the run gets a sandbox with a real shell.",
595                env.fallback_notice
596                    .as_deref()
597                    .unwrap_or("local: true was requested")
598            )));
599        }
600
601        let seam = self.seam();
602        let asm = build_assistant_runtime(
603            seam.engine.clone(),
604            env,
605            None,
606            None,
607            None,
608            self.trajectories.clone(),
609            // An MCP `run` has no operator at the keyboard to have asked for a
610            // delegating run; keep the sub-agent tool off this surface.
611            false,
612        )
613        .await
614        .map_err(|e| refused(&format!("could not assemble the assistant runtime: {e}")))?;
615        // NO host-connectivity probe is installed here, and that is the
616        // decision, not an omission.
617        //
618        // The headless flip's premise is that the drawer can BE this
619        // browser's visible surface. For an `assistant_start` run that
620        // premise does not hold: the view is registered under this run's own
621        // `mcp-run-<uuid>` key (below), the drawer only ever subscribes to
622        // `agents.chat` session ids or the standing session, and there is no
623        // discovery method — no `browser.view.list`, and the run id travels
624        // only in this call's JSON result, back to the MCP caller. So the key
625        // is unsubscribable by construction.
626        //
627        // Installing the daemon-wide probe made "some client authenticated
628        // with the host token" stand in for "someone can see this browser",
629        // and the two are not the same thing here: with CarHost running — the
630        // ordinary state on an operator Mac — the run went headless AND
631        // unwatchable, and `browser_await_signin`'s host-gone escape could not
632        // fire either (it needs `!host_connected`), so a sign-in blocked for
633        // its full timeout with a strip raised into a view nothing was
634        // watching and no window on screen.
635        //
636        // With no probe, `any_host_connected()` answers false and
637        // `decide_headless` launches HEADED — a real window the person can
638        // actually complete a sign-in in, which is what this path did before
639        // the drawer existed. Condition-driven, not a toggle:
640        // `CAR_BROWSER_HEADLESS` still overrides in either direction for an
641        // operator who wants otherwise. When a run's browser becomes
642        // reachable from the drawer, install a probe that answers for THAT
643        // view rather than for the daemon.
644        req.system = prompt::batch_prompt(&asm.identity, &asm.description, &asm.tools);
645
646        let cfg = AssistantConfig {
647            model: req.model.clone(),
648            strict_model: false,
649            max_turns: req.max_turns,
650            tools: asm.tools.clone(),
651            gated_tools: asm.gated_tools.clone(),
652            // No approval transport exists on an MCP tool call, so a gated tool
653            // is denied rather than queued for a human who is not there. That
654            // is what makes `local: true` a read-only run.
655            approval_policy: None,
656            proactive_memory: Some(asm.proactive_memory.clone()),
657            tool_memory: Some(asm.tool_memory.clone()),
658            tool_labels: None,
659            todos: Some(Arc::clone(&asm.todos)),
660            value_store_previews: crate::assistant::agent_loop::VALUE_STORE_PREVIEWS_DEFAULT,
661            response_format: None,
662            context_window_override: None,
663            refuse_unadvertised_tools: false,
664            response_format_validator: None,
665            delegate_budget: None,
666        };
667
668        let id = format!("mcp-run-{}", uuid::Uuid::new_v4().simple());
669        let entry = Arc::new(RunEntry {
670            id: id.clone(),
671            ancestry: seed_ancestry_in(&self.base_ancestry, req.invoked_by.as_deref()),
672            created_at: now_secs(),
673            last_poll: AtomicU64::new(now_secs()),
674            outcome: StdMutex::new(RunOutcome::default()),
675            events: StdMutex::new(EventBuffer::new(self.bounds.event_buffer_max)),
676            cancel: Arc::new(AtomicBool::new(false)),
677            task: StdMutex::new(None),
678        });
679        self.runs.lock().await.insert(id.clone(), entry.clone());
680        let entry_for_task = entry.clone();
681
682        let sandbox = posture.to_json();
683        // Publish this run's browser as a `browser.view.*` view, so the
684        // Command Deck's drawer can watch (and, on Take control, drive) the
685        // browser THIS run actually uses. Chromium is still un-launched —
686        // registering costs nothing for a run that never browses.
687        //
688        // The guard is what ties the view's lifetime to the run's: it
689        // unregisters on any way the run's task can end, including a reap
690        // that aborts it mid-flight (an aborted future is still dropped),
691        // which is also where `note_run_ended` fires — the drawer's strip
692        // disappears and every control accepts input again.
693        //
694        // The conversation key is known HERE and nowhere inside
695        // `BrowserTools`, so this is also where the operator-attention sink
696        // is bound: a browse call that blocks on `browser_await_signin` can
697        // otherwise sit for up to 1800s with the drawer closed and no sign of
698        // it anywhere (`crate::browser_attention`). Installed before
699        // `register`, so it is in place before anything can reach a browse
700        // call.
701        asm.browser.set_signin_attention(
702            Arc::new(crate::browser_attention::HostSignInAttention::new(
703                Arc::clone(&self.state.host),
704            )),
705            Some(id.clone()),
706        );
707        let view = self
708            .state
709            .browser_views
710            .register(id.clone(), Arc::clone(&asm.browser))
711            .await;
712        let view_guard = BrowserViewGuard {
713            view,
714            views: Arc::clone(&self.state.browser_views),
715        };
716        let handle = tokio::spawn(async move {
717            let _view_guard = view_guard;
718            run_task(
719                entry_for_task,
720                seam.generator,
721                asm.runtime,
722                cfg,
723                posture,
724                req,
725                slot,
726            )
727            .await;
728        });
729        *lock(&entry.task) = Some(handle);
730
731        Ok(json!({
732            "run_id": id,
733            "status": RunStatus::Running.as_str(),
734            "poll_after_ms": POLL_AFTER_MS,
735            // Reported at start, not only at the end: a caller that asked for a
736            // sandbox and silently got the local host should learn that before
737            // it hands the run anything sensitive.
738            "sandbox": sandbox,
739            "ancestry": entry.ancestry,
740        }))
741    }
742
743    /// `assistant_poll` — everything since `since_seq`, plus the run's state.
744    pub async fn poll(&self, args: &Value) -> Result<Value, ToolError> {
745        let run_id = str_arg(args, "run_id")?.ok_or_else(|| missing("run_id"))?;
746        let since_seq = u64_arg(args, "since_seq")?.unwrap_or(0);
747        let entry = match self.runs.lock().await.get(&run_id).cloned() {
748            Some(e) => e,
749            // A tool *execution* error, so the model reads it and can react. A
750            // restarted daemon is the common cause and it is not the caller
751            // having got the protocol wrong.
752            None => {
753                return Err(refused(&format!(
754                    "run not found: {run_id} — the daemon may have restarted. A run handle \
755                     lives in memory and does not survive one. Start again with \
756                     assistant_start."
757                )))
758            }
759        };
760        entry.touch();
761        // Before reading the outcome, not after: a run whose task panicked has
762        // no outcome to read, and this is the only place anything notices.
763        entry.settle_if_task_died();
764
765        // `next_seq` is read under the SAME lock as the events it follows. Two
766        // locks would let an event land in between: the caller would be told to
767        // resume past a seq it was never given, which is a silent gap of
768        // exactly the kind `events_skipped` exists to make impossible.
769        let (events, events_skipped, next_seq) = {
770            let buffer = lock(&entry.events);
771            let (events, skipped) = buffer.since(since_seq);
772            (events, skipped, buffer.next_seq)
773        };
774        let (status, doc) = {
775            let out = lock(&entry.outcome);
776            (out.status.unwrap_or(RunStatus::Running), out.doc.clone())
777        };
778
779        let mut result = json!({
780            "run_id": entry.id,
781            "status": status.as_str(),
782            "events": events,
783            "next_seq": next_seq,
784            // Stated rather than implied: a poll that silently skipped 300
785            // events reads as a complete stream to anyone who does not check.
786            "events_skipped": events_skipped,
787            "ancestry": entry.ancestry,
788            "created_at": entry.created_at,
789        });
790        match status {
791            RunStatus::Running => {
792                result["poll_after_ms"] = json!(POLL_AFTER_MS);
793            }
794            // The `car.do/1` document, verbatim — `summary`, `turns`,
795            // `receipts`, `ungrounded_claims`, `sandbox`, and `goal` when the
796            // run had an `until`. Absent only on a reaped run, which was
797            // stopped before it could produce one.
798            _ => {
799                if let Some(doc) = doc {
800                    result["result"] = doc;
801                }
802            }
803        }
804        Ok(result)
805    }
806
807    /// `assistant_cancel` — ask a run to stop at its next turn boundary.
808    pub async fn cancel(&self, args: &Value) -> Result<Value, ToolError> {
809        let run_id = str_arg(args, "run_id")?.ok_or_else(|| missing("run_id"))?;
810        let entry = self.runs.lock().await.get(&run_id).cloned();
811        let status = match entry {
812            None => "unknown",
813            // Idempotent, and a no-op success: a run that already finished was
814            // not going to do anything else anyway, and reporting that as a
815            // failure would make every "cancel then poll" sequence look broken.
816            Some(entry) if entry.status().is_terminal() => "already_terminal",
817            Some(entry) => {
818                entry.touch();
819                entry.request_cancel();
820                "cancelled"
821            }
822        };
823        Ok(json!({ "run_id": run_id, "status": status }))
824    }
825}
826
827/// Drive one run to a terminal state and settle its document.
828///
829/// `_slot` rides along so the concurrency permit is released exactly when this
830/// task ends — including when the reaper aborts it.
831#[allow(clippy::too_many_arguments)]
832async fn run_task(
833    entry: Arc<RunEntry>,
834    generator: Arc<dyn TurnGenerator>,
835    runtime: car_engine::Runtime,
836    cfg: AssistantConfig,
837    posture: SandboxPosture,
838    req: StartArgs,
839    _slot: OwnedSemaphorePermit,
840) {
841    let emitter = JsonEmitter::new(posture, Arc::new(RunSink(Arc::downgrade(&entry))));
842    emitter.started(&req.task, cfg.model.as_deref().unwrap_or("(router)"));
843
844    let description = req.system.clone();
845    let (outcome, goal) = match req.until.clone() {
846        None => {
847            let mut messages = vec![
848                Message::System {
849                    content: description,
850                },
851                Message::User {
852                    content: req.task.clone(),
853                },
854            ];
855            let outcome = run_assistant_loop_cancellable(
856                generator.as_ref(),
857                &runtime,
858                &cfg,
859                &mut messages,
860                &entry.cancel,
861                None,
862                None,
863                |ev: AssistantEvent| emitter.on_assistant_event(&ev),
864            )
865            .await;
866            (outcome, None)
867        }
868        Some(check) => {
869            let (outcome, report) = goal_run(
870                &entry,
871                &emitter,
872                generator.as_ref(),
873                &runtime,
874                &cfg,
875                &req,
876                &check,
877                description,
878            )
879            .await;
880            (outcome, Some(report))
881        }
882    };
883
884    let cancelled = outcome.status == "cancelled";
885    let doc = emitter.finish(&outcome, goal.as_ref());
886    let status = run_status_for(doc["status"].as_str(), cancelled);
887    entry.settle(status, Some(doc));
888}
889
890/// Map a finished `car.do/1` document to this run's terminal status.
891///
892/// Split out so the mapping is testable and so every non-answer has to be
893/// named here rather than falling into `Ok` by omission. `auth_required` is a
894/// refusal, not a completed run: reporting it as `ok` would tell a caller the
895/// work finished and leave the remedy buried in a document it had no reason to
896/// read. `Error` is the minimal honest answer — the document travels with the
897/// status, so `reason` and `message` are still there for a caller that wants to
898/// render the sign-in step.
899fn run_status_for(doc_status: Option<&str>, cancelled: bool) -> RunStatus {
900    match doc_status {
901        Some("error") | Some("auth_required") => RunStatus::Error,
902        _ if cancelled => RunStatus::Cancelled,
903        _ => RunStatus::Ok,
904    }
905}
906
907/// Goal mode: re-drive the agent until `check` exits 0 on the substrate. The
908/// completion decision is a real command the runtime runs and audits, not a
909/// model reading its own transcript — the same loop `car do --until` drives.
910#[allow(clippy::too_many_arguments)]
911async fn goal_run(
912    entry: &Arc<RunEntry>,
913    emitter: &JsonEmitter,
914    generator: &dyn TurnGenerator,
915    runtime: &car_engine::Runtime,
916    cfg: &AssistantConfig,
917    req: &StartArgs,
918    check: &str,
919    system: String,
920) -> (crate::assistant::AssistantOutcome, GoalReport) {
921    use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
922
923    let spec = GoalSpec {
924        goal: req.task.clone(),
925        condition: GoalCondition::Command {
926            id: "goal_check".into(),
927            expect_exit: 0,
928        },
929        governor: GoalGovernor {
930            max_turns: Some(GOAL_MAX_ITERATIONS),
931            ..Default::default()
932        },
933    };
934    let mut messages = vec![Message::System {
935        content: format!(
936            "{system}\n\nYou are working toward a goal. Completion is verified \
937             deterministically by running this shell command:\n  {check}\nIt is done \
938             only when that command exits 0. Keep working until it does."
939        ),
940    }];
941    let result = run_assistant_goal_loop(
942        generator,
943        runtime,
944        cfg,
945        &mut messages,
946        &entry.cancel,
947        None,
948        &spec,
949        |_outcome| {
950            let cmd = check.to_string();
951            async move {
952                let exit = goal_check_exit(runtime, cfg, &cmd).await;
953                let mut g = car_engine::GoalGather::default();
954                g.command_exits.insert("goal_check".into(), exit);
955                g
956            }
957        },
958        |ev: AssistantEvent| emitter.on_assistant_event(&ev),
959    )
960    .await;
961
962    let report = GoalReport {
963        check: check.to_string(),
964        passed: matches!(result.run.status, GoalStatus::Achieved),
965        // Reported alongside `passed`, never folded into it: a goal a model
966        // judge signed off is not the same result as one the check confirmed.
967        grounded: result.run.grounded,
968        iterations: result.run.iterations,
969        halt: match &result.run.status {
970            GoalStatus::Achieved => None,
971            GoalStatus::Halted { halt } => Some(halt.as_str().to_string()),
972        },
973    };
974    (result.outcome, report)
975}
976
977/// Run the completion check through the runtime, so it is validated, policed,
978/// and audited like any other tool call.
979async fn goal_check_exit(
980    runtime: &car_engine::Runtime,
981    cfg: &AssistantConfig,
982    command: &str,
983) -> i32 {
984    // Should not be reachable — `start` refuses `until` when shell is gated —
985    // but a gated check that silently "passed" would be the worst possible
986    // failure of a completion check.
987    if cfg.gated_tools.iter().any(|tool| tool == "shell") {
988        return 1;
989    }
990    let proposal: car_ir::ActionProposal = serde_json::from_value(json!({
991        "source": "goal-check",
992        "actions": [{
993            "id": "goal_check",
994            "type": "tool_call",
995            "tool": "shell",
996            "parameters": { "command": command },
997        }],
998    }))
999    .expect("static shell-check proposal shape");
1000    let exec = runtime.execute(&proposal).await;
1001    exec.results
1002        .first()
1003        .and_then(|r| r.output.as_ref())
1004        .and_then(|o| o.get("exit_code"))
1005        .and_then(|v| v.as_i64())
1006        .unwrap_or(1) as i32
1007}
1008
1009// ---------------------------------------------------------------------------
1010// Arguments
1011// ---------------------------------------------------------------------------
1012
1013/// Parsed `assistant_start` arguments.
1014#[derive(Clone)]
1015struct StartArgs {
1016    task: String,
1017    cwd: PathBuf,
1018    until: Option<String>,
1019    max_turns: u32,
1020    local: bool,
1021    model: Option<String>,
1022    invoked_by: Option<String>,
1023    /// Filled in after the runtime is assembled — the system prompt describing
1024    /// the bound environment and the tools.
1025    system: String,
1026}
1027
1028impl StartArgs {
1029    fn parse(args: &Value) -> Result<Self, ToolError> {
1030        let task = str_arg(args, "task")?
1031            .filter(|t| !t.trim().is_empty())
1032            .ok_or_else(|| missing("task"))?;
1033        // The daemon's own cwd is the fallback, not the answer: a daemon
1034        // started by a login item is rooted wherever the login item was, which
1035        // is almost never the project. A caller-supplied root is the
1036        // `coder.discuss.start { repo }` precedent.
1037        let cwd = match str_arg(args, "cwd")?.filter(|c| !c.trim().is_empty()) {
1038            Some(c) => PathBuf::from(c),
1039            None => std::env::current_dir().map_err(|e| {
1040                refused(&format!(
1041                    "no cwd was given and the daemon's is unresolvable: {e}"
1042                ))
1043            })?,
1044        };
1045        let max_turns = u64_arg(args, "max_turns")?
1046            .map(|n| (n as u32).clamp(1, MAX_MAX_TURNS))
1047            .unwrap_or(DEFAULT_MAX_TURNS);
1048        Ok(Self {
1049            task,
1050            cwd,
1051            until: str_arg(args, "until")?.filter(|c| !c.trim().is_empty()),
1052            max_turns,
1053            local: args.get("local").and_then(Value::as_bool).unwrap_or(false),
1054            model: str_arg(args, "model")?.filter(|m| !m.trim().is_empty()),
1055            invoked_by: str_arg(args, "invoked_by")?,
1056            system: String::new(),
1057        })
1058    }
1059}
1060
1061fn missing(field: &str) -> ToolError {
1062    ToolError::InvalidParams(format!("missing {field}"))
1063}
1064
1065/// A refusal the model should see: [`ToolError::Internal`] comes back as a
1066/// normal result carrying `isError: true`, not as a JSON-RPC error the client
1067/// swallows.
1068fn refused(message: &str) -> ToolError {
1069    ToolError::Internal(message.to_string())
1070}
1071
1072fn str_arg(args: &Value, key: &str) -> Result<Option<String>, ToolError> {
1073    match args.get(key) {
1074        None | Some(Value::Null) => Ok(None),
1075        Some(Value::String(s)) => Ok(Some(s.clone())),
1076        Some(_) => Err(ToolError::InvalidParams(format!("{key} must be a string"))),
1077    }
1078}
1079
1080fn u64_arg(args: &Value, key: &str) -> Result<Option<u64>, ToolError> {
1081    match args.get(key) {
1082        None | Some(Value::Null) => Ok(None),
1083        Some(v) => v.as_u64().map(Some).ok_or_else(|| {
1084            ToolError::InvalidParams(format!("{key} must be a non-negative integer"))
1085        }),
1086    }
1087}
1088
1089// ---------------------------------------------------------------------------
1090// Registration
1091// ---------------------------------------------------------------------------
1092
1093macro_rules! tool_handler {
1094    ($name:ident, $method:ident) => {
1095        struct $name(Arc<AssistantRunRegistry>);
1096
1097        #[async_trait::async_trait]
1098        impl ToolHandler for $name {
1099            async fn call(&self, args: Value) -> Result<String, ToolError> {
1100                let v = self.0.$method(&args).await?;
1101                serde_json::to_string(&v).map_err(|e| ToolError::Internal(e.to_string()))
1102            }
1103        }
1104    };
1105}
1106
1107tool_handler!(StartTool, start);
1108tool_handler!(PollTool, poll);
1109tool_handler!(CancelTool, cancel);
1110
1111/// Register `assistant_start` / `assistant_poll` / `assistant_cancel` on
1112/// `server`, backed by a fresh run registry over `state`.
1113///
1114/// Called by the daemon only. Every schema carries all four annotation hints,
1115/// which [`car_mcp::Server::register_tool`] enforces — the seam does not route
1116/// around the gate the built-ins pass.
1117pub fn register_assistant_tools(
1118    server: &mut car_mcp::Server,
1119    state: Arc<ServerState>,
1120) -> Result<(), RegisterError> {
1121    let registry = AssistantRunRegistry::new(state);
1122    server.register_tool(start_schema(), Arc::new(StartTool(registry.clone())))?;
1123    server.register_tool(poll_schema(), Arc::new(PollTool(registry.clone())))?;
1124    server.register_tool(cancel_schema(), Arc::new(CancelTool(registry)))?;
1125    Ok(())
1126}
1127
1128fn start_schema() -> Value {
1129    json!({
1130        "name": "assistant_start",
1131        "description": "Start a CAR assistant run (the agent behind `car do`) and return a \
1132                        run handle immediately. Poll it with assistant_poll; stop it with \
1133                        assistant_cancel. A run takes minutes, so this never blocks. The \
1134                        handle lives in the daemon's memory and does NOT survive a daemon \
1135                        restart. By default the run executes in a Docker sandbox with no \
1136                        network; `local: true` runs on the host read-only — writes and \
1137                        shell are refused, because a tool call has no way to ask a human \
1138                        for approval. If your host is itself an agent CLI, set `invoked_by` \
1139                        to its adapter id (claude-code, codex, gemini) so the run records \
1140                        the invocation chain it is part of.",
1141        "inputSchema": {
1142            "type": "object",
1143            "properties": {
1144                "task": { "type": "string", "description": "What the assistant should do." },
1145                "cwd": {
1146                    "type": "string",
1147                    "description": "Working directory for the run. Defaults to the daemon's, which is usually not your project.",
1148                },
1149                "until": {
1150                    "type": "string",
1151                    "description": "Goal mode: keep working until this shell command exits 0. Requires the sandbox (a local run cannot use shell).",
1152                },
1153                "max_turns": {
1154                    "type": "integer",
1155                    "minimum": 1,
1156                    "maximum": MAX_MAX_TURNS,
1157                    "description": "Safety cap on agent turns. Default 50.",
1158                },
1159                "local": {
1160                    "type": "boolean",
1161                    "description": "Run on the host instead of the sandbox. Read-only: writes and shell are refused.",
1162                },
1163                "model": { "type": "string", "description": "Pin a model. Default: CAR's router picks." },
1164                "invoked_by": {
1165                    "type": "string",
1166                    "description": "Your own adapter id if you are an agent CLI: claude-code, codex, or gemini. Recorded on the run and echoed by assistant_poll as `ancestry`.",
1167                },
1168            },
1169            "required": ["task"],
1170        },
1171        "annotations": {
1172            "readOnlyHint": false,
1173            // It runs an autonomous agent with a real shell. Whether a given
1174            // run overwrites anything is not knowable up front, and a host
1175            // deciding whether to prompt should assume the worse case.
1176            "destructiveHint": true,
1177            "idempotentHint": false,
1178            // Not because of the web tools — `web_search` and `http_request`
1179            // declare `full_access`, which exceeds every tier this surface
1180            // binds, so they are gated and, with no approval transport, always
1181            // denied. It is open-world because the caller hands an autonomous
1182            // agent a free-text task: what it reads and touches inside its root
1183            // is decided by the model at runtime, not by these arguments.
1184            "openWorldHint": true,
1185        },
1186    })
1187}
1188
1189fn poll_schema() -> Value {
1190    json!({
1191        "name": "assistant_poll",
1192        "description": "Read progress from an assistant run. Returns events at or after \
1193                        `since_seq` plus `next_seq` to pass to the following poll. `status` \
1194                        is running | ok | error | cancelled and describes the HANDLE; once \
1195                        terminal, `result` carries the car.do/1 document (summary, turns, \
1196                        receipts, ungrounded_claims, sandbox) whose own `status` describes \
1197                        the WORK — success | max_turns | stalled | goal_pending | cancelled \
1198                        | error. `events_skipped` is non-zero when the buffer trimmed its \
1199                        head before you read it. Poll incrementally: a poll with \
1200                        `since_seq: 0` on a long run can return up to 2000 buffered events \
1201                        in one result, all of which land in your context. An unknown run_id \
1202                        means the run finished long ago or the daemon restarted.",
1203        "inputSchema": {
1204            "type": "object",
1205            "properties": {
1206                "run_id": { "type": "string" },
1207                "since_seq": {
1208                    "type": "integer",
1209                    "minimum": 0,
1210                    "description": "First event seq to return. Use next_seq from the previous poll; 0 for the whole buffer.",
1211                },
1212            },
1213            "required": ["run_id"],
1214        },
1215        "annotations": {
1216            "readOnlyHint": true,
1217            "destructiveHint": false,
1218            "idempotentHint": true,
1219            "openWorldHint": false,
1220        },
1221    })
1222}
1223
1224fn cancel_schema() -> Value {
1225    json!({
1226        "name": "assistant_cancel",
1227        "description": "Stop an assistant run. The run stops at its next TURN BOUNDARY, not \
1228                        mid-model-call, so expect one more turn's worth of activity — then \
1229                        poll for the car.do/1 document describing what it had done. Returns \
1230                        cancelled | already_terminal | unknown; cancelling a finished or \
1231                        unknown run is a successful no-op.",
1232        "inputSchema": {
1233            "type": "object",
1234            "properties": { "run_id": { "type": "string" } },
1235            "required": ["run_id"],
1236        },
1237        "annotations": {
1238            "readOnlyHint": false,
1239            "destructiveHint": false,
1240            "idempotentHint": true,
1241            "openWorldHint": false,
1242        },
1243    })
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    /// An account refusal is not a completed run. Before this, `poll` answered
1249    /// `ok` for a turn that never reached a model, which contradicts this
1250    /// module's own contract that poll says what happened.
1251    #[test]
1252    fn an_account_refusal_is_not_reported_as_a_completed_run() {
1253        use super::{run_status_for, RunStatus};
1254        assert_eq!(
1255            run_status_for(Some("auth_required"), false),
1256            RunStatus::Error
1257        );
1258        assert_eq!(run_status_for(Some("error"), false), RunStatus::Error);
1259        assert_eq!(run_status_for(Some("success"), false), RunStatus::Ok);
1260        assert_eq!(run_status_for(Some("max_turns"), false), RunStatus::Ok);
1261        assert_eq!(run_status_for(Some("success"), true), RunStatus::Cancelled);
1262        // A cancel does not outrank a refusal that already has a document.
1263        assert_eq!(
1264            run_status_for(Some("auth_required"), true),
1265            RunStatus::Error
1266        );
1267    }
1268
1269    use super::*;
1270    use async_trait::async_trait;
1271    use car_inference::{GenerateRequest, InferenceResult};
1272    use std::sync::atomic::AtomicUsize;
1273
1274    use crate::assistant::browser_control::ControlOwner;
1275
1276    /// The guard fires asynchronously, so a run-end can arrive long after a
1277    /// NEW run has taken the same conversation key. Re-resolving the key at
1278    /// fire time landed the transition on the successor's browser — clearing
1279    /// its strip and re-opening input to everyone while that agent was
1280    /// actively driving. Holding the view's `Arc` makes a late arrival inert.
1281    #[tokio::test]
1282    async fn a_late_run_end_cannot_touch_the_successor_s_view() {
1283        let temp = tempfile::tempdir().unwrap();
1284        let state = Arc::new(ServerState::with_config(
1285            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
1286        ));
1287        let make = || {
1288            Arc::new(crate::assistant::browser_tools::BrowserTools::new(
1289                temp.path().to_path_buf(),
1290            ))
1291        };
1292
1293        // Run 1 registers, and its guard is created — exactly as `start` does.
1294        let first_tools = make();
1295        let first = state
1296            .browser_views
1297            .register("conv-1", Arc::clone(&first_tools))
1298            .await;
1299        first_tools.attach_agent_for_test().await;
1300        let first_guard = BrowserViewGuard {
1301            view: Arc::clone(&first),
1302            views: Arc::clone(&state.browser_views),
1303        };
1304
1305        // Run 2 takes the same key, and its agent is driving.
1306        let second_tools = make();
1307        let second = state
1308            .browser_views
1309            .register("conv-1", Arc::clone(&second_tools))
1310            .await;
1311        second_tools.attach_agent_for_test().await;
1312
1313        // Run 1's guard finally drops.
1314        drop(first_guard);
1315        for _ in 0..100 {
1316            if first_tools.control_status().await.owner == ControlOwner::NoAgent {
1317                break;
1318            }
1319            tokio::task::yield_now().await;
1320        }
1321
1322        assert_eq!(
1323            first_tools.control_status().await.owner,
1324            ControlOwner::NoAgent,
1325            "the run that actually ended is the one handed back"
1326        );
1327        assert_eq!(
1328            second_tools.control_status().await.owner,
1329            ControlOwner::Agent,
1330            "the successor's agent must still be driving its own browser"
1331        );
1332        assert!(
1333            state
1334                .browser_views
1335                .get(Some("conv-1"))
1336                .await
1337                .is_some_and(|v| Arc::ptr_eq(&v, &second)),
1338            "and the key still resolves to the successor"
1339        );
1340    }
1341
1342    /// The run-lifecycle hook, on the path that has no end of the function
1343    /// body to hang it off: a REAPED run's task is aborted, and an aborted
1344    /// future is still dropped, so the guard is the only thing that fires the
1345    /// run-ended transition. Without it, a reaped run leaves its drawer
1346    /// permanently showing an agent that is not there, with every control
1347    /// refusing input.
1348    ///
1349    /// It must NOT unregister a view a drawer is WATCHING: the browser
1350    /// outlives its run by design, so the view is still there afterwards —
1351    /// user-owned and drivable, showing the last page exactly as the agent
1352    /// left it. (The unwatched case is the opposite, and is the leak fix —
1353    /// see `dropping_the_guard_releases_a_view_nobody_is_watching`.)
1354    #[tokio::test]
1355    async fn dropping_the_guard_ends_the_run_s_browser_view() {
1356        let temp = tempfile::tempdir().unwrap();
1357        let state = Arc::new(ServerState::with_config(
1358            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
1359        ));
1360        let tools = Arc::new(crate::assistant::browser_tools::BrowserTools::new(
1361            temp.path().to_path_buf(),
1362        ));
1363        state
1364            .browser_views
1365            .register("run-1", Arc::clone(&tools))
1366            .await;
1367        tools.attach_agent_for_test().await;
1368        let view = state
1369            .browser_views
1370            .get(Some("run-1"))
1371            .await
1372            .expect("registered");
1373        assert_eq!(
1374            view.tools().control_status().await.owner,
1375            ControlOwner::Agent
1376        );
1377
1378        // A drawer is watching this view — the case the "browser outlives its
1379        // run" guarantee is actually about.
1380        let (channel, _frames) = crate::session::WsChannel::test_capture();
1381        view.subscribe_for_test("host-1", Arc::new(channel)).await;
1382
1383        // A task that is aborted before it can finish — the reap path.
1384        let guard = BrowserViewGuard {
1385            view: Arc::clone(&view),
1386            views: Arc::clone(&state.browser_views),
1387        };
1388        let task = tokio::spawn(async move {
1389            let _guard = guard;
1390            std::future::pending::<()>().await;
1391        });
1392        task.abort();
1393        let _ = task.await;
1394
1395        // `note_run_ended` is spawned from `drop`, so give it a turn to land.
1396        for _ in 0..100 {
1397            if view.tools().control_status().await.owner == ControlOwner::NoAgent {
1398                break;
1399            }
1400            tokio::task::yield_now().await;
1401        }
1402        assert_eq!(
1403            view.tools().control_status().await.owner,
1404            ControlOwner::NoAgent,
1405            "an aborted run must still hand its browser back to the user"
1406        );
1407        assert!(
1408            state.browser_views.get(Some("run-1")).await.is_some(),
1409            "and must NOT unregister a view a drawer is watching — the browser outlives the run"
1410        );
1411    }
1412
1413    /// The leak: `register`-replacement is the documented lifetime bound, and
1414    /// it is unreachable here because `start` keys on `mcp-run-<uuid>`, minted
1415    /// fresh per run. Before the release path existed, every assistant run
1416    /// that browsed left a live Chromium registered for the daemon's whole
1417    /// lifetime — 50 runs, 50 idle browsers, no eviction anywhere in the
1418    /// crate. Nobody is subscribed here, so nothing is being shown to anyone.
1419    #[tokio::test]
1420    async fn dropping_the_guard_releases_a_view_nobody_is_watching() {
1421        let temp = tempfile::tempdir().unwrap();
1422        let state = Arc::new(ServerState::with_config(
1423            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
1424        ));
1425        let tools = Arc::new(crate::assistant::browser_tools::BrowserTools::new(
1426            temp.path().to_path_buf(),
1427        ));
1428        // A `Weak` is the honest probe: "released" means the last strong
1429        // reference to the browser is gone, not merely that a map key stopped
1430        // resolving.
1431        let weak = Arc::downgrade(&tools);
1432        let key = format!("mcp-run-{}", uuid::Uuid::new_v4().simple());
1433        let view = state
1434            .browser_views
1435            .register(key.clone(), Arc::clone(&tools))
1436            .await;
1437        tools.attach_agent_for_test().await;
1438        drop(tools);
1439
1440        let guard = BrowserViewGuard {
1441            view: Arc::clone(&view),
1442            views: Arc::clone(&state.browser_views),
1443        };
1444        drop(guard);
1445        drop(view);
1446
1447        for _ in 0..100 {
1448            if state.browser_views.get(Some(&key)).await.is_none() {
1449                break;
1450            }
1451            tokio::task::yield_now().await;
1452        }
1453        assert!(
1454            state.browser_views.get(Some(&key)).await.is_none(),
1455            "a finished run's unwatched view must not stay registered forever"
1456        );
1457        assert!(
1458            weak.upgrade().is_none(),
1459            "and its browser must actually be released, not just unkeyed"
1460        );
1461    }
1462
1463    impl AssistantRunRegistry {
1464        /// A registry with caller-chosen bounds and a scripted model, and NO
1465        /// background reaper — the tests call [`Self::reap_idle`] directly.
1466        ///
1467        /// Bounds are injected rather than the clock mocked: driving the TTL by
1468        /// setting it to zero and the buffer trim by setting it to four proves
1469        /// the same code paths a 3600-second TTL and a 2000-event buffer would,
1470        /// without a test that sleeps for an hour or scripts 2000 turns.
1471        fn for_test(state: Arc<ServerState>, bounds: RunBounds, model: ModelSeam) -> Arc<Self> {
1472            Arc::new(Self {
1473                state,
1474                runs: tokio::sync::Mutex::new(HashMap::new()),
1475                slots: Arc::new(Semaphore::new(bounds.max_open_runs)),
1476                bounds,
1477                model: Some(model),
1478                // Never the user's real trajectory store: a test that runs a
1479                // scripted agent must not skew the per-tool success rates
1480                // `verify.monte_carlo` reads back.
1481                trajectories: None,
1482                // Empty, not `recursion::ancestry()`: a test asserting on a
1483                // run's ancestry must not read `$CAR_INVOKED_BY`, which this
1484                // repo's own plugin manifest sets. The merge of a non-empty
1485                // base with a per-call `invoked_by` is covered where it lives,
1486                // by `recursion`'s `seed_ancestry_in` tests.
1487                base_ancestry: Vec::new(),
1488            })
1489        }
1490    }
1491
1492    fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1493        serde_json::from_value(json!({
1494            "text": text, "tool_calls": tool_calls,
1495            "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1496        }))
1497        .expect("scripted InferenceResult shape")
1498    }
1499
1500    /// A turn that calls `calculate` — a tool that needs no substrate and is
1501    /// not approval-gated, so it exercises the receipt path without depending
1502    /// on Docker or a human.
1503    fn calculate_turn(text: &str, expression: &str) -> InferenceResult {
1504        turn(
1505            text,
1506            json!([{
1507                "id": "c1",
1508                "name": "calculate",
1509                "arguments": { "expression": expression },
1510            }]),
1511        )
1512    }
1513
1514    /// A turn that calls `shell` — the tool `local: true` must refuse. The
1515    /// command is harmless on purpose: if the gate ever regresses, this test
1516    /// should fail on the assertion, not by doing something to the host.
1517    fn shell_turn(text: &str, command: &str) -> InferenceResult {
1518        turn(
1519            text,
1520            json!([{
1521                "id": "s1",
1522                "name": "shell",
1523                "arguments": { "command": command },
1524            }]),
1525        )
1526    }
1527
1528    /// A model that panics instead of answering.
1529    ///
1530    /// The only way into the "task ended without settling" branch from a test.
1531    /// It prints a panic backtrace into the test output; that is the scripted
1532    /// panic, not a failure.
1533    struct Panics;
1534
1535    #[async_trait]
1536    impl TurnGenerator for Panics {
1537        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1538            panic!("scripted panic inside a run task");
1539        }
1540    }
1541
1542    /// A scripted model that optionally hands control to the test at each turn.
1543    ///
1544    /// `entered`/`release` are what make the cancellation test deterministic:
1545    /// the test waits until the model is genuinely mid-turn, cancels, and only
1546    /// then lets the turn finish — so the run reaches the next turn boundary
1547    /// with the flag already set, which is exactly the boundary the tool
1548    /// description promises cancellation lands on.
1549    struct Script {
1550        turns: Vec<InferenceResult>,
1551        cursor: AtomicUsize,
1552        entered: Option<Arc<tokio::sync::Notify>>,
1553        release: Option<Arc<tokio::sync::Notify>>,
1554    }
1555
1556    impl Script {
1557        fn new(turns: Vec<InferenceResult>) -> Arc<Self> {
1558            Arc::new(Self {
1559                turns,
1560                cursor: AtomicUsize::new(0),
1561                entered: None,
1562                release: None,
1563            })
1564        }
1565
1566        fn gated(
1567            turns: Vec<InferenceResult>,
1568            entered: Arc<tokio::sync::Notify>,
1569            release: Arc<tokio::sync::Notify>,
1570        ) -> Arc<Self> {
1571            Arc::new(Self {
1572                turns,
1573                cursor: AtomicUsize::new(0),
1574                entered: Some(entered),
1575                release: Some(release),
1576            })
1577        }
1578    }
1579
1580    #[async_trait]
1581    impl TurnGenerator for Script {
1582        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1583            if let Some(entered) = &self.entered {
1584                // `notify_one` stores a permit, so it does not matter whether
1585                // the test is already waiting when this runs.
1586                entered.notify_one();
1587            }
1588            if let Some(release) = &self.release {
1589                release.notified().await;
1590            }
1591            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1592            self.turns
1593                .get(i)
1594                .cloned()
1595                .ok_or_else(|| "script exhausted".to_string())
1596        }
1597    }
1598
1599    /// A real engine that is never asked to generate — it supplies the runtime
1600    /// while the `Script` answers turns, the pattern `coder::discuss`'s tests
1601    /// use. Pointed at a temp models dir so nothing reaches the user's cache.
1602    fn seam(root: &std::path::Path, generator: Arc<dyn TurnGenerator>) -> ModelSeam {
1603        let mut cfg = car_inference::InferenceConfig::default();
1604        cfg.models_dir = root.join("models");
1605        ModelSeam {
1606            engine: Arc::new(car_inference::InferenceEngine::new(cfg)),
1607            generator,
1608        }
1609    }
1610
1611    fn state() -> (Arc<ServerState>, tempfile::TempDir) {
1612        let journal = tempfile::tempdir().unwrap();
1613        let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
1614        (state, journal)
1615    }
1616
1617    /// `local: true` throughout: a test must not depend on Docker being
1618    /// installed, and the scripted turns never call a gated tool anyway.
1619    fn start_args(cwd: &std::path::Path, task: &str) -> Value {
1620        json!({ "task": task, "cwd": cwd.display().to_string(), "local": true })
1621    }
1622
1623    async fn poll(registry: &AssistantRunRegistry, run_id: &str, since: u64) -> Value {
1624        registry
1625            .poll(&json!({ "run_id": run_id, "since_seq": since }))
1626            .await
1627            .expect("poll")
1628    }
1629
1630    /// Poll until the handle leaves `running`, or fail rather than hang.
1631    async fn await_terminal(registry: &AssistantRunRegistry, run_id: &str) -> Value {
1632        for _ in 0..500 {
1633            let v = poll(registry, run_id, 0).await;
1634            if v["status"] != "running" {
1635                return v;
1636            }
1637            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1638        }
1639        panic!("run {run_id} never reached a terminal status");
1640    }
1641
1642    #[tokio::test]
1643    async fn start_poll_and_cancel_round_trip() {
1644        let dir = tempfile::tempdir().unwrap();
1645        let (state, _journal) = state();
1646        let registry = AssistantRunRegistry::for_test(
1647            state,
1648            RunBounds::default(),
1649            seam(dir.path(), Script::new(vec![turn("all done", json!([]))])),
1650        );
1651
1652        let started = registry
1653            .start(&start_args(dir.path(), "say you are done"))
1654            .await
1655            .expect("start");
1656        let run_id = started["run_id"].as_str().expect("run_id").to_string();
1657        assert!(run_id.starts_with("mcp-run-"), "{run_id}");
1658        assert_eq!(started["status"], "running");
1659        assert_eq!(started["poll_after_ms"], POLL_AFTER_MS);
1660
1661        let done = await_terminal(&registry, &run_id).await;
1662        assert_eq!(done["status"], "ok", "{done}");
1663        assert_eq!(done["events_skipped"], 0);
1664        // The terminal payload IS the car.do/1 document, not a second envelope
1665        // wrapping it.
1666        assert_eq!(done["result"]["schema"], "car.do/1");
1667        assert_eq!(done["result"]["summary"], "all done");
1668        assert!(done["result"]["receipts"]["total"].is_number(), "{done}");
1669        // ...and the events are the car.do/1 JSONL events, plus a seq.
1670        let events = done["events"].as_array().expect("events");
1671        assert_eq!(events[0]["type"], "started");
1672        assert_eq!(events[0]["seq"], 0);
1673        assert!(
1674            events.iter().any(|e| e["type"] == "completed"),
1675            "{events:?}"
1676        );
1677        assert_eq!(done["next_seq"], events.len() as u64);
1678
1679        // An incremental poll returns only what is new.
1680        let tail = poll(&registry, &run_id, done["next_seq"].as_u64().unwrap()).await;
1681        assert!(tail["events"].as_array().unwrap().is_empty(), "{tail}");
1682    }
1683
1684    #[tokio::test]
1685    async fn cancel_lands_at_the_next_turn_boundary() {
1686        let dir = tempfile::tempdir().unwrap();
1687        let (state, _journal) = state();
1688        let entered = Arc::new(tokio::sync::Notify::new());
1689        let release = Arc::new(tokio::sync::Notify::new());
1690        let registry = AssistantRunRegistry::for_test(
1691            state,
1692            RunBounds::default(),
1693            seam(
1694                dir.path(),
1695                Script::gated(
1696                    vec![
1697                        calculate_turn("working", "1 + 1"),
1698                        turn("never reached", json!([])),
1699                    ],
1700                    entered.clone(),
1701                    release.clone(),
1702                ),
1703            ),
1704        );
1705
1706        let started = registry
1707            .start(&start_args(dir.path(), "keep going"))
1708            .await
1709            .expect("start");
1710        let run_id = started["run_id"].as_str().unwrap().to_string();
1711
1712        // Wait until the model is genuinely mid-turn, THEN cancel: the flag is
1713        // checked at the top of the next turn, never inside this one.
1714        entered.notified().await;
1715        let cancelled = registry
1716            .cancel(&json!({ "run_id": run_id }))
1717            .await
1718            .expect("cancel");
1719        assert_eq!(cancelled["status"], "cancelled");
1720        release.notify_one();
1721
1722        let done = await_terminal(&registry, &run_id).await;
1723        assert_eq!(done["status"], "cancelled", "{done}");
1724        // The document still describes what the run had done before stopping —
1725        // the tool call from turn one is in the receipts.
1726        assert_eq!(done["result"]["status"], "cancelled");
1727        assert_eq!(done["result"]["receipts"]["total"], 1);
1728
1729        // Cancelling a finished run is a successful no-op, not an error: a host
1730        // that always cancels after reading the result must not see a failure.
1731        let again = registry
1732            .cancel(&json!({ "run_id": run_id }))
1733            .await
1734            .expect("cancel again");
1735        assert_eq!(again["status"], "already_terminal");
1736    }
1737
1738    #[tokio::test]
1739    async fn a_start_past_the_cap_is_refused_and_creates_no_run() {
1740        let dir = tempfile::tempdir().unwrap();
1741        let (state, _journal) = state();
1742        let entered = Arc::new(tokio::sync::Notify::new());
1743        let release = Arc::new(tokio::sync::Notify::new());
1744        // Two slots rather than the real eight: it is the same code path, and
1745        // eight live runtimes per test run is a lot of setup for a bound the
1746        // constant already parameterizes.
1747        let bounds = RunBounds {
1748            max_open_runs: 2,
1749            ..RunBounds::default()
1750        };
1751        let registry = AssistantRunRegistry::for_test(
1752            state,
1753            bounds,
1754            seam(
1755                dir.path(),
1756                Script::gated(
1757                    vec![turn("done", json!([]))],
1758                    entered.clone(),
1759                    release.clone(),
1760                ),
1761            ),
1762        );
1763
1764        for _ in 0..2 {
1765            registry
1766                .start(&start_args(dir.path(), "hold a slot"))
1767                .await
1768                .expect("start within the cap");
1769        }
1770        let refused = registry
1771            .start(&start_args(dir.path(), "one too many"))
1772            .await
1773            .expect_err("past the cap");
1774        // An execution error, so the model reads the refusal and can act on it.
1775        assert!(refused.is_execution_error());
1776        let message = refused.message().to_string();
1777        assert!(message.contains('2'), "the cap must be named: {message}");
1778        assert!(
1779            message.contains("assistant_cancel"),
1780            "the way out must be named: {message}"
1781        );
1782        assert_eq!(registry.runs.lock().await.len(), 2, "no run was created");
1783    }
1784
1785    /// The cap bounds *execution*, not retention.
1786    ///
1787    /// The permit lives in the run's task rather than its registry entry, so a
1788    /// client that polls a run to completion can start the next one straight
1789    /// away instead of waiting out the hour-long idle TTL for a record that is
1790    /// finished and costs nothing.
1791    #[tokio::test]
1792    async fn a_finished_run_does_not_keep_holding_its_slot() {
1793        let dir = tempfile::tempdir().unwrap();
1794        let (state, _journal) = state();
1795        let bounds = RunBounds {
1796            max_open_runs: 1,
1797            ..RunBounds::default()
1798        };
1799        let registry = AssistantRunRegistry::for_test(
1800            state,
1801            bounds,
1802            seam(
1803                dir.path(),
1804                Script::new(vec![turn("first", json!([])), turn("second", json!([]))]),
1805            ),
1806        );
1807
1808        let first = registry
1809            .start(&start_args(dir.path(), "the first run"))
1810            .await
1811            .expect("start");
1812        let run_id = first["run_id"].as_str().unwrap().to_string();
1813        await_terminal(&registry, &run_id).await;
1814        // The finished record is still pollable...
1815        assert_eq!(registry.runs.lock().await.len(), 1);
1816
1817        // ...and does not block the next run. Retried because the permit drops
1818        // when the task future ends, a scheduler tick after the status settles.
1819        for attempt in 0..100 {
1820            if registry
1821                .start(&start_args(dir.path(), "the second run"))
1822                .await
1823                .is_ok()
1824            {
1825                return;
1826            }
1827            assert!(attempt < 99, "a finished run never released its slot");
1828            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1829        }
1830    }
1831
1832    #[tokio::test]
1833    async fn a_run_nobody_polls_is_reaped_and_its_slot_returned() {
1834        let dir = tempfile::tempdir().unwrap();
1835        let (state, _journal) = state();
1836        let entered = Arc::new(tokio::sync::Notify::new());
1837        let release = Arc::new(tokio::sync::Notify::new());
1838        // One slot, and the REAL idle TTL — the test backdates the run's last
1839        // poll rather than shortening the constant, so what is exercised is the
1840        // shipped 3600-second bound and not a test-only zero.
1841        let bounds = RunBounds {
1842            max_open_runs: 1,
1843            ..RunBounds::default()
1844        };
1845        let registry = AssistantRunRegistry::for_test(
1846            state,
1847            bounds,
1848            seam(
1849                dir.path(),
1850                Script::gated(
1851                    vec![turn("done", json!([]))],
1852                    entered.clone(),
1853                    release.clone(),
1854                ),
1855            ),
1856        );
1857
1858        let started = registry
1859            .start(&start_args(dir.path(), "abandon me"))
1860            .await
1861            .expect("start");
1862        let run_id = started["run_id"].as_str().unwrap().to_string();
1863        entered.notified().await; // genuinely executing, and never released
1864
1865        registry.runs.lock().await[&run_id]
1866            .last_poll
1867            .store(now_secs() - RUN_IDLE_TTL_SECS - 1, Ordering::SeqCst);
1868        registry.reap_idle().await;
1869        assert!(registry.runs.lock().await.is_empty());
1870        // And the handle answers honestly rather than reporting an empty
1871        // "running" forever.
1872        let err = registry
1873            .poll(&json!({ "run_id": run_id }))
1874            .await
1875            .expect_err("reaped");
1876        assert!(err.message().contains("run not found"), "{}", err.message());
1877
1878        // The slot comes back once the reaped task is actually gone — which is
1879        // what "the cap bounds live runs" has to mean.
1880        for attempt in 0..100 {
1881            if registry
1882                .start(&start_args(dir.path(), "the next run"))
1883                .await
1884                .is_ok()
1885            {
1886                return;
1887            }
1888            assert!(attempt < 99, "the reaped run never released its slot");
1889            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1890        }
1891    }
1892
1893    #[tokio::test]
1894    async fn a_trimmed_event_buffer_states_the_gap() {
1895        let dir = tempfile::tempdir().unwrap();
1896        let (state, _journal) = state();
1897        let bounds = RunBounds {
1898            event_buffer_max: 4,
1899            ..RunBounds::default()
1900        };
1901        let registry = AssistantRunRegistry::for_test(
1902            state,
1903            bounds,
1904            seam(
1905                dir.path(),
1906                Script::new(vec![
1907                    calculate_turn("step one", "1 + 1"),
1908                    calculate_turn("step two", "2 + 2"),
1909                    calculate_turn("step three", "3 + 3"),
1910                    turn("finished", json!([])),
1911                ]),
1912            ),
1913        );
1914
1915        let started = registry
1916            .start(&start_args(dir.path(), "do several things"))
1917            .await
1918            .expect("start");
1919        let run_id = started["run_id"].as_str().unwrap().to_string();
1920        let done = await_terminal(&registry, &run_id).await;
1921
1922        let events = done["events"].as_array().expect("events");
1923        assert!(
1924            events.len() <= 4,
1925            "buffer was not trimmed: {}",
1926            events.len()
1927        );
1928        assert!(
1929            done["events_skipped"].as_u64().unwrap() > 0,
1930            "a trimmed head must be stated, not silent: {done}"
1931        );
1932        // The surviving events keep their original seqs, so a caller can tell
1933        // exactly where the gap is rather than inferring it.
1934        assert!(events[0]["seq"].as_u64().unwrap() > 0, "{events:?}");
1935        assert_eq!(
1936            done["events_skipped"].as_u64().unwrap(),
1937            events[0]["seq"].as_u64().unwrap()
1938        );
1939    }
1940
1941    #[tokio::test]
1942    async fn a_caller_that_names_itself_gets_an_ancestry_and_one_that_does_not_gets_none() {
1943        let dir = tempfile::tempdir().unwrap();
1944        let (state, _journal) = state();
1945        let registry = AssistantRunRegistry::for_test(
1946            state,
1947            RunBounds::default(),
1948            seam(dir.path(), Script::new(vec![turn("done", json!([]))])),
1949        );
1950
1951        let mut args = start_args(dir.path(), "run under a host");
1952        args["invoked_by"] = json!("Claude-Code");
1953        let named = registry.start(&args).await.expect("start");
1954        assert_eq!(named["ancestry"], json!(["claude-code"]));
1955        // ...and it travels with the run rather than being re-read from the
1956        // daemon's process environment on each poll.
1957        let run_id = named["run_id"].as_str().unwrap().to_string();
1958        assert_eq!(
1959            await_terminal(&registry, &run_id).await["ancestry"],
1960            json!(["claude-code"])
1961        );
1962
1963        // A caller that names nothing gets the registry's stored base chain,
1964        // which `for_test` sets empty. This assertion is on the registry's
1965        // field, NOT on `$CAR_INVOKED_BY` — which this repo's own
1966        // `plugins/car/.mcp.json` sets to `claude-code`, so an env-reading
1967        // version of this test failed for every agent-driven run.
1968        let anonymous = registry
1969            .start(&start_args(dir.path(), "run from nowhere"))
1970            .await
1971            .expect("start");
1972        assert_eq!(anonymous["ancestry"], json!([]));
1973    }
1974
1975    /// The §7 claim the whole recursion story rests on: what closes
1976    /// CAR → `shell` → `claude -p` → CAR is the *posture*, not the ancestry.
1977    /// Every other test here scripts `calculate`, which is never gated, so
1978    /// without this one a regression in `bind_default_substrate`'s tier or in
1979    /// `build_assistant_runtime`'s `gated_tools` wiring would open the hole
1980    /// silently.
1981    #[tokio::test]
1982    async fn a_local_run_refuses_shell_and_still_settles() {
1983        let dir = tempfile::tempdir().unwrap();
1984        let (state, _journal) = state();
1985        let registry = AssistantRunRegistry::for_test(
1986            state,
1987            RunBounds::default(),
1988            seam(
1989                dir.path(),
1990                Script::new(vec![
1991                    shell_turn("let me look around", "true"),
1992                    turn("could not run that", json!([])),
1993                ]),
1994            ),
1995        );
1996
1997        let started = registry
1998            .start(&start_args(dir.path(), "run a shell command"))
1999            .await
2000            .expect("start");
2001        // The tier is reported at start, and it is the tier the refusal below
2002        // depends on.
2003        assert_eq!(started["sandbox"]["tier"], "ReadOnly", "{started}");
2004        let run_id = started["run_id"].as_str().unwrap().to_string();
2005
2006        let done = await_terminal(&registry, &run_id).await;
2007        // A refused tool does not sink the run: the model is told and the run
2008        // still produces a document.
2009        assert_eq!(done["status"], "ok", "{done}");
2010        assert_eq!(done["result"]["schema"], "car.do/1");
2011        let events = done["events"].as_array().expect("events");
2012        assert!(
2013            events
2014                .iter()
2015                .any(|e| e["type"] == "tool_failed" && e["data"]["tool"] == "shell"),
2016            "shell was not refused: {events:?}"
2017        );
2018        assert!(
2019            !events
2020                .iter()
2021                .any(|e| e["type"] == "tool_result" && e["data"]["tool"] == "shell"),
2022            "shell RAN on a local: true run: {events:?}"
2023        );
2024        // Refused at the gate, not attempted: the failed receipt makes the
2025        // refusal inspectable without claiming the runtime dispatched it. The
2026        // corresponding event remains `tool_failed` (never `tool_result`), and
2027        // the bounded sample preserves the denied call's identity.
2028        assert_eq!(done["result"]["receipts"]["total"], 1, "{done}");
2029        assert_eq!(done["result"]["receipts"]["failed"], 1, "{done}");
2030        assert_eq!(
2031            done["result"]["receipts"]["sample"][0],
2032            json!({"brief": "true", "ok": false, "tool": "shell"}),
2033            "{done}"
2034        );
2035    }
2036
2037    #[tokio::test]
2038    async fn a_run_whose_task_panics_settles_instead_of_polling_forever() {
2039        let dir = tempfile::tempdir().unwrap();
2040        let (state, _journal) = state();
2041        let registry = AssistantRunRegistry::for_test(
2042            state,
2043            RunBounds::default(),
2044            seam(dir.path(), Arc::new(Panics)),
2045        );
2046
2047        let started = registry
2048            .start(&start_args(dir.path(), "die mid-run"))
2049            .await
2050            .expect("start");
2051        let run_id = started["run_id"].as_str().unwrap().to_string();
2052
2053        // Before the fix this looped 500 times and then failed: the task was
2054        // gone, nothing joined it, and every poll re-`touch`ed the entry out of
2055        // the reaper's reach, so `running` was permanent.
2056        let done = await_terminal(&registry, &run_id).await;
2057        assert_eq!(done["status"], "error", "{done}");
2058        assert_eq!(done["result"]["schema"], "car.do/1");
2059        assert_eq!(done["result"]["error"], "run_task_died", "{done}");
2060        // And it stays settled — a second poll must not re-decide.
2061        let again = poll(&registry, &run_id, 0).await;
2062        assert_eq!(again["status"], "error", "{again}");
2063    }
2064
2065    #[tokio::test]
2066    async fn a_missing_task_is_a_protocol_error_not_a_refusal() {
2067        let dir = tempfile::tempdir().unwrap();
2068        let (state, _journal) = state();
2069        let registry = AssistantRunRegistry::for_test(
2070            state,
2071            RunBounds::default(),
2072            seam(dir.path(), Script::new(vec![])),
2073        );
2074        // The tool never ran, so this takes the JSON-RPC error channel — the
2075        // car#972 §5 split, inherited rather than reinvented.
2076        let e = registry
2077            .start(&json!({ "cwd": "." }))
2078            .await
2079            .expect_err("no task");
2080        assert!(!e.is_execution_error());
2081        assert!(e.message().contains("task"), "{}", e.message());
2082    }
2083
2084    /// The counterpart to `car_mcp`'s `the_stdio_server_offers_no_assistant_tools`.
2085    /// One test alone cannot pin "daemon only"; these two together do.
2086    #[tokio::test]
2087    async fn the_daemon_server_advertises_all_three() {
2088        let (state, _journal) = state();
2089        let mut server = car_mcp::Server::new();
2090        register_assistant_tools(&mut server, state).expect("registers");
2091
2092        let resp = server
2093            .handle(
2094                serde_json::from_value(json!({
2095                    "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {},
2096                }))
2097                .expect("request"),
2098            )
2099            .await
2100            .expect("response");
2101        let names: Vec<String> = resp.result.expect("result")["tools"]
2102            .as_array()
2103            .expect("array")
2104            .iter()
2105            .map(|t| t["name"].as_str().expect("name").to_string())
2106            .collect();
2107        for tool in ["assistant_start", "assistant_poll", "assistant_cancel"] {
2108            assert!(names.iter().any(|n| n == tool), "{tool} missing: {names:?}");
2109        }
2110    }
2111}