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