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(®istry);
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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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}