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 handle costs nothing on the endpoint's wire posture. `handle_mcp_post`
12//! reads no session header and this changes none of that: the `run_id` is
13//! application state the **client** carries between calls, exactly like the
14//! opaque `resources/list` cursor. Progress *notifications* would have cost the
15//! stateless POST — 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 = if doc["status"] == "error" {
887        RunStatus::Error
888    } else if cancelled {
889        RunStatus::Cancelled
890    } else {
891        RunStatus::Ok
892    };
893    entry.settle(status, Some(doc));
894}
895
896/// Goal mode: re-drive the agent until `check` exits 0 on the substrate. The
897/// completion decision is a real command the runtime runs and audits, not a
898/// model reading its own transcript — the same loop `car do --until` drives.
899#[allow(clippy::too_many_arguments)]
900async fn goal_run(
901    entry: &Arc<RunEntry>,
902    emitter: &JsonEmitter,
903    generator: &dyn TurnGenerator,
904    runtime: &car_engine::Runtime,
905    cfg: &AssistantConfig,
906    req: &StartArgs,
907    check: &str,
908    system: String,
909) -> (crate::assistant::AssistantOutcome, GoalReport) {
910    use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
911
912    let spec = GoalSpec {
913        goal: req.task.clone(),
914        condition: GoalCondition::Command {
915            id: "goal_check".into(),
916            expect_exit: 0,
917        },
918        governor: GoalGovernor {
919            max_turns: Some(GOAL_MAX_ITERATIONS),
920            ..Default::default()
921        },
922    };
923    let mut messages = vec![Message::System {
924        content: format!(
925            "{system}\n\nYou are working toward a goal. Completion is verified \
926             deterministically by running this shell command:\n  {check}\nIt is done \
927             only when that command exits 0. Keep working until it does."
928        ),
929    }];
930    let result = run_assistant_goal_loop(
931        generator,
932        runtime,
933        cfg,
934        &mut messages,
935        &entry.cancel,
936        None,
937        &spec,
938        |_outcome| {
939            let cmd = check.to_string();
940            async move {
941                let exit = goal_check_exit(runtime, cfg, &cmd).await;
942                let mut g = car_engine::GoalGather::default();
943                g.command_exits.insert("goal_check".into(), exit);
944                g
945            }
946        },
947        |ev: AssistantEvent| emitter.on_assistant_event(&ev),
948    )
949    .await;
950
951    let report = GoalReport {
952        check: check.to_string(),
953        passed: matches!(result.run.status, GoalStatus::Achieved),
954        // Reported alongside `passed`, never folded into it: a goal a model
955        // judge signed off is not the same result as one the check confirmed.
956        grounded: result.run.grounded,
957        iterations: result.run.iterations,
958        halt: match &result.run.status {
959            GoalStatus::Achieved => None,
960            GoalStatus::Halted { halt } => Some(halt.as_str().to_string()),
961        },
962    };
963    (result.outcome, report)
964}
965
966/// Run the completion check through the runtime, so it is validated, policed,
967/// and audited like any other tool call.
968async fn goal_check_exit(
969    runtime: &car_engine::Runtime,
970    cfg: &AssistantConfig,
971    command: &str,
972) -> i32 {
973    // Should not be reachable — `start` refuses `until` when shell is gated —
974    // but a gated check that silently "passed" would be the worst possible
975    // failure of a completion check.
976    if cfg.gated_tools.iter().any(|tool| tool == "shell") {
977        return 1;
978    }
979    let proposal: car_ir::ActionProposal = serde_json::from_value(json!({
980        "source": "goal-check",
981        "actions": [{
982            "id": "goal_check",
983            "type": "tool_call",
984            "tool": "shell",
985            "parameters": { "command": command },
986        }],
987    }))
988    .expect("static shell-check proposal shape");
989    let exec = runtime.execute(&proposal).await;
990    exec.results
991        .first()
992        .and_then(|r| r.output.as_ref())
993        .and_then(|o| o.get("exit_code"))
994        .and_then(|v| v.as_i64())
995        .unwrap_or(1) as i32
996}
997
998// ---------------------------------------------------------------------------
999// Arguments
1000// ---------------------------------------------------------------------------
1001
1002/// Parsed `assistant_start` arguments.
1003#[derive(Clone)]
1004struct StartArgs {
1005    task: String,
1006    cwd: PathBuf,
1007    until: Option<String>,
1008    max_turns: u32,
1009    local: bool,
1010    model: Option<String>,
1011    invoked_by: Option<String>,
1012    /// Filled in after the runtime is assembled — the system prompt describing
1013    /// the bound environment and the tools.
1014    system: String,
1015}
1016
1017impl StartArgs {
1018    fn parse(args: &Value) -> Result<Self, ToolError> {
1019        let task = str_arg(args, "task")?
1020            .filter(|t| !t.trim().is_empty())
1021            .ok_or_else(|| missing("task"))?;
1022        // The daemon's own cwd is the fallback, not the answer: a daemon
1023        // started by a login item is rooted wherever the login item was, which
1024        // is almost never the project. A caller-supplied root is the
1025        // `coder.discuss.start { repo }` precedent.
1026        let cwd = match str_arg(args, "cwd")?.filter(|c| !c.trim().is_empty()) {
1027            Some(c) => PathBuf::from(c),
1028            None => std::env::current_dir().map_err(|e| {
1029                refused(&format!(
1030                    "no cwd was given and the daemon's is unresolvable: {e}"
1031                ))
1032            })?,
1033        };
1034        let max_turns = u64_arg(args, "max_turns")?
1035            .map(|n| (n as u32).clamp(1, MAX_MAX_TURNS))
1036            .unwrap_or(DEFAULT_MAX_TURNS);
1037        Ok(Self {
1038            task,
1039            cwd,
1040            until: str_arg(args, "until")?.filter(|c| !c.trim().is_empty()),
1041            max_turns,
1042            local: args.get("local").and_then(Value::as_bool).unwrap_or(false),
1043            model: str_arg(args, "model")?.filter(|m| !m.trim().is_empty()),
1044            invoked_by: str_arg(args, "invoked_by")?,
1045            system: String::new(),
1046        })
1047    }
1048}
1049
1050fn missing(field: &str) -> ToolError {
1051    ToolError::InvalidParams(format!("missing {field}"))
1052}
1053
1054/// A refusal the model should see: [`ToolError::Internal`] comes back as a
1055/// normal result carrying `isError: true`, not as a JSON-RPC error the client
1056/// swallows.
1057fn refused(message: &str) -> ToolError {
1058    ToolError::Internal(message.to_string())
1059}
1060
1061fn str_arg(args: &Value, key: &str) -> Result<Option<String>, ToolError> {
1062    match args.get(key) {
1063        None | Some(Value::Null) => Ok(None),
1064        Some(Value::String(s)) => Ok(Some(s.clone())),
1065        Some(_) => Err(ToolError::InvalidParams(format!("{key} must be a string"))),
1066    }
1067}
1068
1069fn u64_arg(args: &Value, key: &str) -> Result<Option<u64>, ToolError> {
1070    match args.get(key) {
1071        None | Some(Value::Null) => Ok(None),
1072        Some(v) => v.as_u64().map(Some).ok_or_else(|| {
1073            ToolError::InvalidParams(format!("{key} must be a non-negative integer"))
1074        }),
1075    }
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Registration
1080// ---------------------------------------------------------------------------
1081
1082macro_rules! tool_handler {
1083    ($name:ident, $method:ident) => {
1084        struct $name(Arc<AssistantRunRegistry>);
1085
1086        #[async_trait::async_trait]
1087        impl ToolHandler for $name {
1088            async fn call(&self, args: Value) -> Result<String, ToolError> {
1089                let v = self.0.$method(&args).await?;
1090                serde_json::to_string(&v).map_err(|e| ToolError::Internal(e.to_string()))
1091            }
1092        }
1093    };
1094}
1095
1096tool_handler!(StartTool, start);
1097tool_handler!(PollTool, poll);
1098tool_handler!(CancelTool, cancel);
1099
1100/// Register `assistant_start` / `assistant_poll` / `assistant_cancel` on
1101/// `server`, backed by a fresh run registry over `state`.
1102///
1103/// Called by the daemon only. Every schema carries all four annotation hints,
1104/// which [`car_mcp::Server::register_tool`] enforces — the seam does not route
1105/// around the gate the built-ins pass.
1106pub fn register_assistant_tools(
1107    server: &mut car_mcp::Server,
1108    state: Arc<ServerState>,
1109) -> Result<(), RegisterError> {
1110    let registry = AssistantRunRegistry::new(state);
1111    server.register_tool(start_schema(), Arc::new(StartTool(registry.clone())))?;
1112    server.register_tool(poll_schema(), Arc::new(PollTool(registry.clone())))?;
1113    server.register_tool(cancel_schema(), Arc::new(CancelTool(registry)))?;
1114    Ok(())
1115}
1116
1117fn start_schema() -> Value {
1118    json!({
1119        "name": "assistant_start",
1120        "description": "Start a CAR assistant run (the agent behind `car do`) and return a \
1121                        run handle immediately. Poll it with assistant_poll; stop it with \
1122                        assistant_cancel. A run takes minutes, so this never blocks. The \
1123                        handle lives in the daemon's memory and does NOT survive a daemon \
1124                        restart. By default the run executes in a Docker sandbox with no \
1125                        network; `local: true` runs on the host read-only — writes and \
1126                        shell are refused, because a tool call has no way to ask a human \
1127                        for approval. If your host is itself an agent CLI, set `invoked_by` \
1128                        to its adapter id (claude-code, codex, gemini) so the run records \
1129                        the invocation chain it is part of.",
1130        "inputSchema": {
1131            "type": "object",
1132            "properties": {
1133                "task": { "type": "string", "description": "What the assistant should do." },
1134                "cwd": {
1135                    "type": "string",
1136                    "description": "Working directory for the run. Defaults to the daemon's, which is usually not your project.",
1137                },
1138                "until": {
1139                    "type": "string",
1140                    "description": "Goal mode: keep working until this shell command exits 0. Requires the sandbox (a local run cannot use shell).",
1141                },
1142                "max_turns": {
1143                    "type": "integer",
1144                    "minimum": 1,
1145                    "maximum": MAX_MAX_TURNS,
1146                    "description": "Safety cap on agent turns. Default 50.",
1147                },
1148                "local": {
1149                    "type": "boolean",
1150                    "description": "Run on the host instead of the sandbox. Read-only: writes and shell are refused.",
1151                },
1152                "model": { "type": "string", "description": "Pin a model. Default: CAR's router picks." },
1153                "invoked_by": {
1154                    "type": "string",
1155                    "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`.",
1156                },
1157            },
1158            "required": ["task"],
1159        },
1160        "annotations": {
1161            "readOnlyHint": false,
1162            // It runs an autonomous agent with a real shell. Whether a given
1163            // run overwrites anything is not knowable up front, and a host
1164            // deciding whether to prompt should assume the worse case.
1165            "destructiveHint": true,
1166            "idempotentHint": false,
1167            // Not because of the web tools — `web_search` and `http_request`
1168            // declare `full_access`, which exceeds every tier this surface
1169            // binds, so they are gated and, with no approval transport, always
1170            // denied. It is open-world because the caller hands an autonomous
1171            // agent a free-text task: what it reads and touches inside its root
1172            // is decided by the model at runtime, not by these arguments.
1173            "openWorldHint": true,
1174        },
1175    })
1176}
1177
1178fn poll_schema() -> Value {
1179    json!({
1180        "name": "assistant_poll",
1181        "description": "Read progress from an assistant run. Returns events at or after \
1182                        `since_seq` plus `next_seq` to pass to the following poll. `status` \
1183                        is running | ok | error | cancelled and describes the HANDLE; once \
1184                        terminal, `result` carries the car.do/1 document (summary, turns, \
1185                        receipts, ungrounded_claims, sandbox) whose own `status` describes \
1186                        the WORK — success | max_turns | stalled | goal_pending | cancelled \
1187                        | error. `events_skipped` is non-zero when the buffer trimmed its \
1188                        head before you read it. Poll incrementally: a poll with \
1189                        `since_seq: 0` on a long run can return up to 2000 buffered events \
1190                        in one result, all of which land in your context. An unknown run_id \
1191                        means the run finished long ago or the daemon restarted.",
1192        "inputSchema": {
1193            "type": "object",
1194            "properties": {
1195                "run_id": { "type": "string" },
1196                "since_seq": {
1197                    "type": "integer",
1198                    "minimum": 0,
1199                    "description": "First event seq to return. Use next_seq from the previous poll; 0 for the whole buffer.",
1200                },
1201            },
1202            "required": ["run_id"],
1203        },
1204        "annotations": {
1205            "readOnlyHint": true,
1206            "destructiveHint": false,
1207            "idempotentHint": true,
1208            "openWorldHint": false,
1209        },
1210    })
1211}
1212
1213fn cancel_schema() -> Value {
1214    json!({
1215        "name": "assistant_cancel",
1216        "description": "Stop an assistant run. The run stops at its next TURN BOUNDARY, not \
1217                        mid-model-call, so expect one more turn's worth of activity — then \
1218                        poll for the car.do/1 document describing what it had done. Returns \
1219                        cancelled | already_terminal | unknown; cancelling a finished or \
1220                        unknown run is a successful no-op.",
1221        "inputSchema": {
1222            "type": "object",
1223            "properties": { "run_id": { "type": "string" } },
1224            "required": ["run_id"],
1225        },
1226        "annotations": {
1227            "readOnlyHint": false,
1228            "destructiveHint": false,
1229            "idempotentHint": true,
1230            "openWorldHint": false,
1231        },
1232    })
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use async_trait::async_trait;
1239    use car_inference::{GenerateRequest, InferenceResult};
1240    use std::sync::atomic::AtomicUsize;
1241
1242    use crate::assistant::browser_control::ControlOwner;
1243
1244    /// The guard fires asynchronously, so a run-end can arrive long after a
1245    /// NEW run has taken the same conversation key. Re-resolving the key at
1246    /// fire time landed the transition on the successor's browser — clearing
1247    /// its strip and re-opening input to everyone while that agent was
1248    /// actively driving. Holding the view's `Arc` makes a late arrival inert.
1249    #[tokio::test]
1250    async fn a_late_run_end_cannot_touch_the_successor_s_view() {
1251        let temp = tempfile::tempdir().unwrap();
1252        let state = Arc::new(ServerState::with_config(
1253            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
1254        ));
1255        let make = || {
1256            Arc::new(crate::assistant::browser_tools::BrowserTools::new(
1257                temp.path().to_path_buf(),
1258            ))
1259        };
1260
1261        // Run 1 registers, and its guard is created — exactly as `start` does.
1262        let first_tools = make();
1263        let first = state
1264            .browser_views
1265            .register("conv-1", Arc::clone(&first_tools))
1266            .await;
1267        first_tools.attach_agent_for_test().await;
1268        let first_guard = BrowserViewGuard {
1269            view: Arc::clone(&first),
1270            views: Arc::clone(&state.browser_views),
1271        };
1272
1273        // Run 2 takes the same key, and its agent is driving.
1274        let second_tools = make();
1275        let second = state
1276            .browser_views
1277            .register("conv-1", Arc::clone(&second_tools))
1278            .await;
1279        second_tools.attach_agent_for_test().await;
1280
1281        // Run 1's guard finally drops.
1282        drop(first_guard);
1283        for _ in 0..100 {
1284            if first_tools.control_status().await.owner == ControlOwner::NoAgent {
1285                break;
1286            }
1287            tokio::task::yield_now().await;
1288        }
1289
1290        assert_eq!(
1291            first_tools.control_status().await.owner,
1292            ControlOwner::NoAgent,
1293            "the run that actually ended is the one handed back"
1294        );
1295        assert_eq!(
1296            second_tools.control_status().await.owner,
1297            ControlOwner::Agent,
1298            "the successor's agent must still be driving its own browser"
1299        );
1300        assert!(
1301            state
1302                .browser_views
1303                .get(Some("conv-1"))
1304                .await
1305                .is_some_and(|v| Arc::ptr_eq(&v, &second)),
1306            "and the key still resolves to the successor"
1307        );
1308    }
1309
1310    /// The run-lifecycle hook, on the path that has no end of the function
1311    /// body to hang it off: a REAPED run's task is aborted, and an aborted
1312    /// future is still dropped, so the guard is the only thing that fires the
1313    /// run-ended transition. Without it, a reaped run leaves its drawer
1314    /// permanently showing an agent that is not there, with every control
1315    /// refusing input.
1316    ///
1317    /// It must NOT unregister a view a drawer is WATCHING: the browser
1318    /// outlives its run by design, so the view is still there afterwards —
1319    /// user-owned and drivable, showing the last page exactly as the agent
1320    /// left it. (The unwatched case is the opposite, and is the leak fix —
1321    /// see `dropping_the_guard_releases_a_view_nobody_is_watching`.)
1322    #[tokio::test]
1323    async fn dropping_the_guard_ends_the_run_s_browser_view() {
1324        let temp = tempfile::tempdir().unwrap();
1325        let state = Arc::new(ServerState::with_config(
1326            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
1327        ));
1328        let tools = Arc::new(crate::assistant::browser_tools::BrowserTools::new(
1329            temp.path().to_path_buf(),
1330        ));
1331        state
1332            .browser_views
1333            .register("run-1", Arc::clone(&tools))
1334            .await;
1335        tools.attach_agent_for_test().await;
1336        let view = state
1337            .browser_views
1338            .get(Some("run-1"))
1339            .await
1340            .expect("registered");
1341        assert_eq!(
1342            view.tools().control_status().await.owner,
1343            ControlOwner::Agent
1344        );
1345
1346        // A drawer is watching this view — the case the "browser outlives its
1347        // run" guarantee is actually about.
1348        let (channel, _frames) = crate::session::WsChannel::test_capture();
1349        view.subscribe_for_test("host-1", Arc::new(channel)).await;
1350
1351        // A task that is aborted before it can finish — the reap path.
1352        let guard = BrowserViewGuard {
1353            view: Arc::clone(&view),
1354            views: Arc::clone(&state.browser_views),
1355        };
1356        let task = tokio::spawn(async move {
1357            let _guard = guard;
1358            std::future::pending::<()>().await;
1359        });
1360        task.abort();
1361        let _ = task.await;
1362
1363        // `note_run_ended` is spawned from `drop`, so give it a turn to land.
1364        for _ in 0..100 {
1365            if view.tools().control_status().await.owner == ControlOwner::NoAgent {
1366                break;
1367            }
1368            tokio::task::yield_now().await;
1369        }
1370        assert_eq!(
1371            view.tools().control_status().await.owner,
1372            ControlOwner::NoAgent,
1373            "an aborted run must still hand its browser back to the user"
1374        );
1375        assert!(
1376            state.browser_views.get(Some("run-1")).await.is_some(),
1377            "and must NOT unregister a view a drawer is watching — the browser outlives the run"
1378        );
1379    }
1380
1381    /// The leak: `register`-replacement is the documented lifetime bound, and
1382    /// it is unreachable here because `start` keys on `mcp-run-<uuid>`, minted
1383    /// fresh per run. Before the release path existed, every assistant run
1384    /// that browsed left a live Chromium registered for the daemon's whole
1385    /// lifetime — 50 runs, 50 idle browsers, no eviction anywhere in the
1386    /// crate. Nobody is subscribed here, so nothing is being shown to anyone.
1387    #[tokio::test]
1388    async fn dropping_the_guard_releases_a_view_nobody_is_watching() {
1389        let temp = tempfile::tempdir().unwrap();
1390        let state = Arc::new(ServerState::with_config(
1391            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
1392        ));
1393        let tools = Arc::new(crate::assistant::browser_tools::BrowserTools::new(
1394            temp.path().to_path_buf(),
1395        ));
1396        // A `Weak` is the honest probe: "released" means the last strong
1397        // reference to the browser is gone, not merely that a map key stopped
1398        // resolving.
1399        let weak = Arc::downgrade(&tools);
1400        let key = format!("mcp-run-{}", uuid::Uuid::new_v4().simple());
1401        let view = state
1402            .browser_views
1403            .register(key.clone(), Arc::clone(&tools))
1404            .await;
1405        tools.attach_agent_for_test().await;
1406        drop(tools);
1407
1408        let guard = BrowserViewGuard {
1409            view: Arc::clone(&view),
1410            views: Arc::clone(&state.browser_views),
1411        };
1412        drop(guard);
1413        drop(view);
1414
1415        for _ in 0..100 {
1416            if state.browser_views.get(Some(&key)).await.is_none() {
1417                break;
1418            }
1419            tokio::task::yield_now().await;
1420        }
1421        assert!(
1422            state.browser_views.get(Some(&key)).await.is_none(),
1423            "a finished run's unwatched view must not stay registered forever"
1424        );
1425        assert!(
1426            weak.upgrade().is_none(),
1427            "and its browser must actually be released, not just unkeyed"
1428        );
1429    }
1430
1431    impl AssistantRunRegistry {
1432        /// A registry with caller-chosen bounds and a scripted model, and NO
1433        /// background reaper — the tests call [`Self::reap_idle`] directly.
1434        ///
1435        /// Bounds are injected rather than the clock mocked: driving the TTL by
1436        /// setting it to zero and the buffer trim by setting it to four proves
1437        /// the same code paths a 3600-second TTL and a 2000-event buffer would,
1438        /// without a test that sleeps for an hour or scripts 2000 turns.
1439        fn for_test(state: Arc<ServerState>, bounds: RunBounds, model: ModelSeam) -> Arc<Self> {
1440            Arc::new(Self {
1441                state,
1442                runs: tokio::sync::Mutex::new(HashMap::new()),
1443                slots: Arc::new(Semaphore::new(bounds.max_open_runs)),
1444                bounds,
1445                model: Some(model),
1446                // Never the user's real trajectory store: a test that runs a
1447                // scripted agent must not skew the per-tool success rates
1448                // `verify.monte_carlo` reads back.
1449                trajectories: None,
1450                // Empty, not `recursion::ancestry()`: a test asserting on a
1451                // run's ancestry must not read `$CAR_INVOKED_BY`, which this
1452                // repo's own plugin manifest sets. The merge of a non-empty
1453                // base with a per-call `invoked_by` is covered where it lives,
1454                // by `recursion`'s `seed_ancestry_in` tests.
1455                base_ancestry: Vec::new(),
1456            })
1457        }
1458    }
1459
1460    fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1461        serde_json::from_value(json!({
1462            "text": text, "tool_calls": tool_calls,
1463            "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1464        }))
1465        .expect("scripted InferenceResult shape")
1466    }
1467
1468    /// A turn that calls `calculate` — a tool that needs no substrate and is
1469    /// not approval-gated, so it exercises the receipt path without depending
1470    /// on Docker or a human.
1471    fn calculate_turn(text: &str, expression: &str) -> InferenceResult {
1472        turn(
1473            text,
1474            json!([{
1475                "id": "c1",
1476                "name": "calculate",
1477                "arguments": { "expression": expression },
1478            }]),
1479        )
1480    }
1481
1482    /// A turn that calls `shell` — the tool `local: true` must refuse. The
1483    /// command is harmless on purpose: if the gate ever regresses, this test
1484    /// should fail on the assertion, not by doing something to the host.
1485    fn shell_turn(text: &str, command: &str) -> InferenceResult {
1486        turn(
1487            text,
1488            json!([{
1489                "id": "s1",
1490                "name": "shell",
1491                "arguments": { "command": command },
1492            }]),
1493        )
1494    }
1495
1496    /// A model that panics instead of answering.
1497    ///
1498    /// The only way into the "task ended without settling" branch from a test.
1499    /// It prints a panic backtrace into the test output; that is the scripted
1500    /// panic, not a failure.
1501    struct Panics;
1502
1503    #[async_trait]
1504    impl TurnGenerator for Panics {
1505        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1506            panic!("scripted panic inside a run task");
1507        }
1508    }
1509
1510    /// A scripted model that optionally hands control to the test at each turn.
1511    ///
1512    /// `entered`/`release` are what make the cancellation test deterministic:
1513    /// the test waits until the model is genuinely mid-turn, cancels, and only
1514    /// then lets the turn finish — so the run reaches the next turn boundary
1515    /// with the flag already set, which is exactly the boundary the tool
1516    /// description promises cancellation lands on.
1517    struct Script {
1518        turns: Vec<InferenceResult>,
1519        cursor: AtomicUsize,
1520        entered: Option<Arc<tokio::sync::Notify>>,
1521        release: Option<Arc<tokio::sync::Notify>>,
1522    }
1523
1524    impl Script {
1525        fn new(turns: Vec<InferenceResult>) -> Arc<Self> {
1526            Arc::new(Self {
1527                turns,
1528                cursor: AtomicUsize::new(0),
1529                entered: None,
1530                release: None,
1531            })
1532        }
1533
1534        fn gated(
1535            turns: Vec<InferenceResult>,
1536            entered: Arc<tokio::sync::Notify>,
1537            release: Arc<tokio::sync::Notify>,
1538        ) -> Arc<Self> {
1539            Arc::new(Self {
1540                turns,
1541                cursor: AtomicUsize::new(0),
1542                entered: Some(entered),
1543                release: Some(release),
1544            })
1545        }
1546    }
1547
1548    #[async_trait]
1549    impl TurnGenerator for Script {
1550        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1551            if let Some(entered) = &self.entered {
1552                // `notify_one` stores a permit, so it does not matter whether
1553                // the test is already waiting when this runs.
1554                entered.notify_one();
1555            }
1556            if let Some(release) = &self.release {
1557                release.notified().await;
1558            }
1559            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1560            self.turns
1561                .get(i)
1562                .cloned()
1563                .ok_or_else(|| "script exhausted".to_string())
1564        }
1565    }
1566
1567    /// A real engine that is never asked to generate — it supplies the runtime
1568    /// while the `Script` answers turns, the pattern `coder::discuss`'s tests
1569    /// use. Pointed at a temp models dir so nothing reaches the user's cache.
1570    fn seam(root: &std::path::Path, generator: Arc<dyn TurnGenerator>) -> ModelSeam {
1571        let mut cfg = car_inference::InferenceConfig::default();
1572        cfg.models_dir = root.join("models");
1573        ModelSeam {
1574            engine: Arc::new(car_inference::InferenceEngine::new(cfg)),
1575            generator,
1576        }
1577    }
1578
1579    fn state() -> (Arc<ServerState>, tempfile::TempDir) {
1580        let journal = tempfile::tempdir().unwrap();
1581        let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
1582        (state, journal)
1583    }
1584
1585    /// `local: true` throughout: a test must not depend on Docker being
1586    /// installed, and the scripted turns never call a gated tool anyway.
1587    fn start_args(cwd: &std::path::Path, task: &str) -> Value {
1588        json!({ "task": task, "cwd": cwd.display().to_string(), "local": true })
1589    }
1590
1591    async fn poll(registry: &AssistantRunRegistry, run_id: &str, since: u64) -> Value {
1592        registry
1593            .poll(&json!({ "run_id": run_id, "since_seq": since }))
1594            .await
1595            .expect("poll")
1596    }
1597
1598    /// Poll until the handle leaves `running`, or fail rather than hang.
1599    async fn await_terminal(registry: &AssistantRunRegistry, run_id: &str) -> Value {
1600        for _ in 0..500 {
1601            let v = poll(registry, run_id, 0).await;
1602            if v["status"] != "running" {
1603                return v;
1604            }
1605            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1606        }
1607        panic!("run {run_id} never reached a terminal status");
1608    }
1609
1610    #[tokio::test]
1611    async fn start_poll_and_cancel_round_trip() {
1612        let dir = tempfile::tempdir().unwrap();
1613        let (state, _journal) = state();
1614        let registry = AssistantRunRegistry::for_test(
1615            state,
1616            RunBounds::default(),
1617            seam(dir.path(), Script::new(vec![turn("all done", json!([]))])),
1618        );
1619
1620        let started = registry
1621            .start(&start_args(dir.path(), "say you are done"))
1622            .await
1623            .expect("start");
1624        let run_id = started["run_id"].as_str().expect("run_id").to_string();
1625        assert!(run_id.starts_with("mcp-run-"), "{run_id}");
1626        assert_eq!(started["status"], "running");
1627        assert_eq!(started["poll_after_ms"], POLL_AFTER_MS);
1628
1629        let done = await_terminal(&registry, &run_id).await;
1630        assert_eq!(done["status"], "ok", "{done}");
1631        assert_eq!(done["events_skipped"], 0);
1632        // The terminal payload IS the car.do/1 document, not a second envelope
1633        // wrapping it.
1634        assert_eq!(done["result"]["schema"], "car.do/1");
1635        assert_eq!(done["result"]["summary"], "all done");
1636        assert!(done["result"]["receipts"]["total"].is_number(), "{done}");
1637        // ...and the events are the car.do/1 JSONL events, plus a seq.
1638        let events = done["events"].as_array().expect("events");
1639        assert_eq!(events[0]["type"], "started");
1640        assert_eq!(events[0]["seq"], 0);
1641        assert!(
1642            events.iter().any(|e| e["type"] == "completed"),
1643            "{events:?}"
1644        );
1645        assert_eq!(done["next_seq"], events.len() as u64);
1646
1647        // An incremental poll returns only what is new.
1648        let tail = poll(&registry, &run_id, done["next_seq"].as_u64().unwrap()).await;
1649        assert!(tail["events"].as_array().unwrap().is_empty(), "{tail}");
1650    }
1651
1652    #[tokio::test]
1653    async fn cancel_lands_at_the_next_turn_boundary() {
1654        let dir = tempfile::tempdir().unwrap();
1655        let (state, _journal) = state();
1656        let entered = Arc::new(tokio::sync::Notify::new());
1657        let release = Arc::new(tokio::sync::Notify::new());
1658        let registry = AssistantRunRegistry::for_test(
1659            state,
1660            RunBounds::default(),
1661            seam(
1662                dir.path(),
1663                Script::gated(
1664                    vec![
1665                        calculate_turn("working", "1 + 1"),
1666                        turn("never reached", json!([])),
1667                    ],
1668                    entered.clone(),
1669                    release.clone(),
1670                ),
1671            ),
1672        );
1673
1674        let started = registry
1675            .start(&start_args(dir.path(), "keep going"))
1676            .await
1677            .expect("start");
1678        let run_id = started["run_id"].as_str().unwrap().to_string();
1679
1680        // Wait until the model is genuinely mid-turn, THEN cancel: the flag is
1681        // checked at the top of the next turn, never inside this one.
1682        entered.notified().await;
1683        let cancelled = registry
1684            .cancel(&json!({ "run_id": run_id }))
1685            .await
1686            .expect("cancel");
1687        assert_eq!(cancelled["status"], "cancelled");
1688        release.notify_one();
1689
1690        let done = await_terminal(&registry, &run_id).await;
1691        assert_eq!(done["status"], "cancelled", "{done}");
1692        // The document still describes what the run had done before stopping —
1693        // the tool call from turn one is in the receipts.
1694        assert_eq!(done["result"]["status"], "cancelled");
1695        assert_eq!(done["result"]["receipts"]["total"], 1);
1696
1697        // Cancelling a finished run is a successful no-op, not an error: a host
1698        // that always cancels after reading the result must not see a failure.
1699        let again = registry
1700            .cancel(&json!({ "run_id": run_id }))
1701            .await
1702            .expect("cancel again");
1703        assert_eq!(again["status"], "already_terminal");
1704    }
1705
1706    #[tokio::test]
1707    async fn a_start_past_the_cap_is_refused_and_creates_no_run() {
1708        let dir = tempfile::tempdir().unwrap();
1709        let (state, _journal) = state();
1710        let entered = Arc::new(tokio::sync::Notify::new());
1711        let release = Arc::new(tokio::sync::Notify::new());
1712        // Two slots rather than the real eight: it is the same code path, and
1713        // eight live runtimes per test run is a lot of setup for a bound the
1714        // constant already parameterizes.
1715        let bounds = RunBounds {
1716            max_open_runs: 2,
1717            ..RunBounds::default()
1718        };
1719        let registry = AssistantRunRegistry::for_test(
1720            state,
1721            bounds,
1722            seam(
1723                dir.path(),
1724                Script::gated(
1725                    vec![turn("done", json!([]))],
1726                    entered.clone(),
1727                    release.clone(),
1728                ),
1729            ),
1730        );
1731
1732        for _ in 0..2 {
1733            registry
1734                .start(&start_args(dir.path(), "hold a slot"))
1735                .await
1736                .expect("start within the cap");
1737        }
1738        let refused = registry
1739            .start(&start_args(dir.path(), "one too many"))
1740            .await
1741            .expect_err("past the cap");
1742        // An execution error, so the model reads the refusal and can act on it.
1743        assert!(refused.is_execution_error());
1744        let message = refused.message().to_string();
1745        assert!(message.contains('2'), "the cap must be named: {message}");
1746        assert!(
1747            message.contains("assistant_cancel"),
1748            "the way out must be named: {message}"
1749        );
1750        assert_eq!(registry.runs.lock().await.len(), 2, "no run was created");
1751    }
1752
1753    /// The cap bounds *execution*, not retention.
1754    ///
1755    /// The permit lives in the run's task rather than its registry entry, so a
1756    /// client that polls a run to completion can start the next one straight
1757    /// away instead of waiting out the hour-long idle TTL for a record that is
1758    /// finished and costs nothing.
1759    #[tokio::test]
1760    async fn a_finished_run_does_not_keep_holding_its_slot() {
1761        let dir = tempfile::tempdir().unwrap();
1762        let (state, _journal) = state();
1763        let bounds = RunBounds {
1764            max_open_runs: 1,
1765            ..RunBounds::default()
1766        };
1767        let registry = AssistantRunRegistry::for_test(
1768            state,
1769            bounds,
1770            seam(
1771                dir.path(),
1772                Script::new(vec![turn("first", json!([])), turn("second", json!([]))]),
1773            ),
1774        );
1775
1776        let first = registry
1777            .start(&start_args(dir.path(), "the first run"))
1778            .await
1779            .expect("start");
1780        let run_id = first["run_id"].as_str().unwrap().to_string();
1781        await_terminal(&registry, &run_id).await;
1782        // The finished record is still pollable...
1783        assert_eq!(registry.runs.lock().await.len(), 1);
1784
1785        // ...and does not block the next run. Retried because the permit drops
1786        // when the task future ends, a scheduler tick after the status settles.
1787        for attempt in 0..100 {
1788            if registry
1789                .start(&start_args(dir.path(), "the second run"))
1790                .await
1791                .is_ok()
1792            {
1793                return;
1794            }
1795            assert!(attempt < 99, "a finished run never released its slot");
1796            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1797        }
1798    }
1799
1800    #[tokio::test]
1801    async fn a_run_nobody_polls_is_reaped_and_its_slot_returned() {
1802        let dir = tempfile::tempdir().unwrap();
1803        let (state, _journal) = state();
1804        let entered = Arc::new(tokio::sync::Notify::new());
1805        let release = Arc::new(tokio::sync::Notify::new());
1806        // One slot, and the REAL idle TTL — the test backdates the run's last
1807        // poll rather than shortening the constant, so what is exercised is the
1808        // shipped 3600-second bound and not a test-only zero.
1809        let bounds = RunBounds {
1810            max_open_runs: 1,
1811            ..RunBounds::default()
1812        };
1813        let registry = AssistantRunRegistry::for_test(
1814            state,
1815            bounds,
1816            seam(
1817                dir.path(),
1818                Script::gated(
1819                    vec![turn("done", json!([]))],
1820                    entered.clone(),
1821                    release.clone(),
1822                ),
1823            ),
1824        );
1825
1826        let started = registry
1827            .start(&start_args(dir.path(), "abandon me"))
1828            .await
1829            .expect("start");
1830        let run_id = started["run_id"].as_str().unwrap().to_string();
1831        entered.notified().await; // genuinely executing, and never released
1832
1833        registry.runs.lock().await[&run_id]
1834            .last_poll
1835            .store(now_secs() - RUN_IDLE_TTL_SECS - 1, Ordering::SeqCst);
1836        registry.reap_idle().await;
1837        assert!(registry.runs.lock().await.is_empty());
1838        // And the handle answers honestly rather than reporting an empty
1839        // "running" forever.
1840        let err = registry
1841            .poll(&json!({ "run_id": run_id }))
1842            .await
1843            .expect_err("reaped");
1844        assert!(err.message().contains("run not found"), "{}", err.message());
1845
1846        // The slot comes back once the reaped task is actually gone — which is
1847        // what "the cap bounds live runs" has to mean.
1848        for attempt in 0..100 {
1849            if registry
1850                .start(&start_args(dir.path(), "the next run"))
1851                .await
1852                .is_ok()
1853            {
1854                return;
1855            }
1856            assert!(attempt < 99, "the reaped run never released its slot");
1857            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1858        }
1859    }
1860
1861    #[tokio::test]
1862    async fn a_trimmed_event_buffer_states_the_gap() {
1863        let dir = tempfile::tempdir().unwrap();
1864        let (state, _journal) = state();
1865        let bounds = RunBounds {
1866            event_buffer_max: 4,
1867            ..RunBounds::default()
1868        };
1869        let registry = AssistantRunRegistry::for_test(
1870            state,
1871            bounds,
1872            seam(
1873                dir.path(),
1874                Script::new(vec![
1875                    calculate_turn("step one", "1 + 1"),
1876                    calculate_turn("step two", "2 + 2"),
1877                    calculate_turn("step three", "3 + 3"),
1878                    turn("finished", json!([])),
1879                ]),
1880            ),
1881        );
1882
1883        let started = registry
1884            .start(&start_args(dir.path(), "do several things"))
1885            .await
1886            .expect("start");
1887        let run_id = started["run_id"].as_str().unwrap().to_string();
1888        let done = await_terminal(&registry, &run_id).await;
1889
1890        let events = done["events"].as_array().expect("events");
1891        assert!(
1892            events.len() <= 4,
1893            "buffer was not trimmed: {}",
1894            events.len()
1895        );
1896        assert!(
1897            done["events_skipped"].as_u64().unwrap() > 0,
1898            "a trimmed head must be stated, not silent: {done}"
1899        );
1900        // The surviving events keep their original seqs, so a caller can tell
1901        // exactly where the gap is rather than inferring it.
1902        assert!(events[0]["seq"].as_u64().unwrap() > 0, "{events:?}");
1903        assert_eq!(
1904            done["events_skipped"].as_u64().unwrap(),
1905            events[0]["seq"].as_u64().unwrap()
1906        );
1907    }
1908
1909    #[tokio::test]
1910    async fn a_caller_that_names_itself_gets_an_ancestry_and_one_that_does_not_gets_none() {
1911        let dir = tempfile::tempdir().unwrap();
1912        let (state, _journal) = state();
1913        let registry = AssistantRunRegistry::for_test(
1914            state,
1915            RunBounds::default(),
1916            seam(dir.path(), Script::new(vec![turn("done", json!([]))])),
1917        );
1918
1919        let mut args = start_args(dir.path(), "run under a host");
1920        args["invoked_by"] = json!("Claude-Code");
1921        let named = registry.start(&args).await.expect("start");
1922        assert_eq!(named["ancestry"], json!(["claude-code"]));
1923        // ...and it travels with the run rather than being re-read from the
1924        // daemon's process environment on each poll.
1925        let run_id = named["run_id"].as_str().unwrap().to_string();
1926        assert_eq!(
1927            await_terminal(&registry, &run_id).await["ancestry"],
1928            json!(["claude-code"])
1929        );
1930
1931        // A caller that names nothing gets the registry's stored base chain,
1932        // which `for_test` sets empty. This assertion is on the registry's
1933        // field, NOT on `$CAR_INVOKED_BY` — which this repo's own
1934        // `plugins/car/.mcp.json` sets to `claude-code`, so an env-reading
1935        // version of this test failed for every agent-driven run.
1936        let anonymous = registry
1937            .start(&start_args(dir.path(), "run from nowhere"))
1938            .await
1939            .expect("start");
1940        assert_eq!(anonymous["ancestry"], json!([]));
1941    }
1942
1943    /// The §7 claim the whole recursion story rests on: what closes
1944    /// CAR → `shell` → `claude -p` → CAR is the *posture*, not the ancestry.
1945    /// Every other test here scripts `calculate`, which is never gated, so
1946    /// without this one a regression in `bind_default_substrate`'s tier or in
1947    /// `build_assistant_runtime`'s `gated_tools` wiring would open the hole
1948    /// silently.
1949    #[tokio::test]
1950    async fn a_local_run_refuses_shell_and_still_settles() {
1951        let dir = tempfile::tempdir().unwrap();
1952        let (state, _journal) = state();
1953        let registry = AssistantRunRegistry::for_test(
1954            state,
1955            RunBounds::default(),
1956            seam(
1957                dir.path(),
1958                Script::new(vec![
1959                    shell_turn("let me look around", "true"),
1960                    turn("could not run that", json!([])),
1961                ]),
1962            ),
1963        );
1964
1965        let started = registry
1966            .start(&start_args(dir.path(), "run a shell command"))
1967            .await
1968            .expect("start");
1969        // The tier is reported at start, and it is the tier the refusal below
1970        // depends on.
1971        assert_eq!(started["sandbox"]["tier"], "ReadOnly", "{started}");
1972        let run_id = started["run_id"].as_str().unwrap().to_string();
1973
1974        let done = await_terminal(&registry, &run_id).await;
1975        // A refused tool does not sink the run: the model is told and the run
1976        // still produces a document.
1977        assert_eq!(done["status"], "ok", "{done}");
1978        assert_eq!(done["result"]["schema"], "car.do/1");
1979        let events = done["events"].as_array().expect("events");
1980        assert!(
1981            events
1982                .iter()
1983                .any(|e| e["type"] == "tool_failed" && e["data"]["tool"] == "shell"),
1984            "shell was not refused: {events:?}"
1985        );
1986        assert!(
1987            !events
1988                .iter()
1989                .any(|e| e["type"] == "tool_result" && e["data"]["tool"] == "shell"),
1990            "shell RAN on a local: true run: {events:?}"
1991        );
1992        // Refused at the gate, not attempted and failed: a refusal never
1993        // reaches the runtime, so it leaves no receipt. This is the assertion
1994        // that distinguishes "the posture closed the cycle" from "the command
1995        // happened to error".
1996        assert_eq!(done["result"]["receipts"]["total"], 0, "{done}");
1997    }
1998
1999    #[tokio::test]
2000    async fn a_run_whose_task_panics_settles_instead_of_polling_forever() {
2001        let dir = tempfile::tempdir().unwrap();
2002        let (state, _journal) = state();
2003        let registry = AssistantRunRegistry::for_test(
2004            state,
2005            RunBounds::default(),
2006            seam(dir.path(), Arc::new(Panics)),
2007        );
2008
2009        let started = registry
2010            .start(&start_args(dir.path(), "die mid-run"))
2011            .await
2012            .expect("start");
2013        let run_id = started["run_id"].as_str().unwrap().to_string();
2014
2015        // Before the fix this looped 500 times and then failed: the task was
2016        // gone, nothing joined it, and every poll re-`touch`ed the entry out of
2017        // the reaper's reach, so `running` was permanent.
2018        let done = await_terminal(&registry, &run_id).await;
2019        assert_eq!(done["status"], "error", "{done}");
2020        assert_eq!(done["result"]["schema"], "car.do/1");
2021        assert_eq!(done["result"]["error"], "run_task_died", "{done}");
2022        // And it stays settled — a second poll must not re-decide.
2023        let again = poll(&registry, &run_id, 0).await;
2024        assert_eq!(again["status"], "error", "{again}");
2025    }
2026
2027    #[tokio::test]
2028    async fn a_missing_task_is_a_protocol_error_not_a_refusal() {
2029        let dir = tempfile::tempdir().unwrap();
2030        let (state, _journal) = state();
2031        let registry = AssistantRunRegistry::for_test(
2032            state,
2033            RunBounds::default(),
2034            seam(dir.path(), Script::new(vec![])),
2035        );
2036        // The tool never ran, so this takes the JSON-RPC error channel — the
2037        // car#972 §5 split, inherited rather than reinvented.
2038        let e = registry
2039            .start(&json!({ "cwd": "." }))
2040            .await
2041            .expect_err("no task");
2042        assert!(!e.is_execution_error());
2043        assert!(e.message().contains("task"), "{}", e.message());
2044    }
2045
2046    /// The counterpart to `car_mcp`'s `the_stdio_server_offers_no_assistant_tools`.
2047    /// One test alone cannot pin "daemon only"; these two together do.
2048    #[tokio::test]
2049    async fn the_daemon_server_advertises_all_three() {
2050        let (state, _journal) = state();
2051        let mut server = car_mcp::Server::new();
2052        register_assistant_tools(&mut server, state).expect("registers");
2053
2054        let resp = server
2055            .handle(
2056                serde_json::from_value(json!({
2057                    "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {},
2058                }))
2059                .expect("request"),
2060            )
2061            .await
2062            .expect("response");
2063        let names: Vec<String> = resp.result.expect("result")["tools"]
2064            .as_array()
2065            .expect("array")
2066            .iter()
2067            .map(|t| t["name"].as_str().expect("name").to_string())
2068            .collect();
2069        for tool in ["assistant_start", "assistant_poll", "assistant_cancel"] {
2070            assert!(names.iter().any(|n| n == tool), "{tool} missing: {names:?}");
2071        }
2072    }
2073}