use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use car_external_agents::recursion::seed_ancestry_in;
use car_inference::tasks::generate::Message;
use car_inference::InferenceEngine;
use car_mcp::{RegisterError, ToolError, ToolHandler};
use car_policy::permission::PermissionTier;
use serde_json::{json, Value};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::assistant::do_json::{
startup_error_doc, EventSink, GoalReport, JsonEmitter, SandboxPosture,
};
use crate::assistant::{
bind_default_substrate, build_assistant_runtime, prompt, run_assistant_goal_loop,
run_assistant_loop_cancellable, AssistantConfig, AssistantEvent, DEFAULT_ASSISTANT_IMAGE,
};
use crate::coder::native_loop::TurnGenerator;
use crate::session::ServerState;
pub const MAX_OPEN_RUNS: usize = 8;
pub const RUN_IDLE_TTL_SECS: u64 = 60 * 60;
pub const RUN_EVENT_BUFFER_MAX: usize = 2000;
const DEFAULT_MAX_TURNS: u32 = 50;
const MAX_MAX_TURNS: u32 = 200;
const GOAL_MAX_ITERATIONS: u32 = 10;
const POLL_AFTER_MS: u64 = 2000;
const REAP_INTERVAL_SECS: u64 = 60;
#[derive(Clone, Copy)]
pub struct RunBounds {
pub max_open_runs: usize,
pub idle_ttl_secs: u64,
pub event_buffer_max: usize,
}
impl Default for RunBounds {
fn default() -> Self {
Self {
max_open_runs: MAX_OPEN_RUNS,
idle_ttl_secs: RUN_IDLE_TTL_SECS,
event_buffer_max: RUN_EVENT_BUFFER_MAX,
}
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn lock<T>(m: &StdMutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum RunStatus {
Running,
Ok,
Error,
Cancelled,
}
impl RunStatus {
fn as_str(self) -> &'static str {
match self {
RunStatus::Running => "running",
RunStatus::Ok => "ok",
RunStatus::Error => "error",
RunStatus::Cancelled => "cancelled",
}
}
fn is_terminal(self) -> bool {
!matches!(self, RunStatus::Running)
}
}
#[derive(Default)]
struct RunOutcome {
status: Option<RunStatus>,
doc: Option<Value>,
}
struct EventBuffer {
events: VecDeque<Value>,
next_seq: u64,
first_seq: u64,
max: usize,
}
impl EventBuffer {
fn new(max: usize) -> Self {
Self {
events: VecDeque::new(),
next_seq: 0,
first_seq: 0,
max,
}
}
fn push(&mut self, mut event: Value) {
let seq = self.next_seq;
self.next_seq += 1;
if let Some(obj) = event.as_object_mut() {
obj.insert("seq".to_string(), json!(seq));
}
self.events.push_back(event);
while self.events.len() > self.max {
self.events.pop_front();
self.first_seq += 1;
}
}
fn since(&self, since_seq: u64) -> (Vec<Value>, u64) {
let events = self
.events
.iter()
.filter(|e| e["seq"].as_u64().unwrap_or(0) >= since_seq)
.cloned()
.collect();
(events, self.first_seq.saturating_sub(since_seq))
}
}
struct RunEntry {
id: String,
ancestry: Vec<String>,
created_at: u64,
last_poll: AtomicU64,
outcome: StdMutex<RunOutcome>,
events: StdMutex<EventBuffer>,
cancel: Arc<AtomicBool>,
task: StdMutex<Option<tokio::task::JoinHandle<()>>>,
}
impl RunEntry {
fn touch(&self) {
self.last_poll.store(now_secs(), Ordering::SeqCst);
}
fn idle_secs(&self) -> u64 {
now_secs().saturating_sub(self.last_poll.load(Ordering::SeqCst))
}
fn status(&self) -> RunStatus {
lock(&self.outcome).status.unwrap_or(RunStatus::Running)
}
fn settle(&self, status: RunStatus, doc: Option<Value>) {
let mut out = lock(&self.outcome);
if out.status.is_some() {
return;
}
out.status = Some(status);
out.doc = doc;
}
fn settle_if_task_died(&self) {
if self.status().is_terminal() {
return;
}
if !lock(&self.task).as_ref().is_some_and(|h| h.is_finished()) {
return;
}
tracing::error!(
run_id = %self.id,
"assistant run task ended without an outcome; reporting it as an error"
);
self.settle(
RunStatus::Error,
Some(startup_error_doc(
"run_task_died",
"the run's task ended without producing a result, which means it panicked. \
Nothing was left running; the events already returned are all there are.",
&[
"Start again with assistant_start.",
"Check the daemon log for the panic.",
],
)),
);
}
fn request_cancel(&self) {
self.cancel.store(true, Ordering::SeqCst);
}
fn abandon(&self) {
self.request_cancel();
self.settle(RunStatus::Cancelled, None);
if let Some(handle) = lock(&self.task).take() {
handle.abort();
}
}
}
struct RunSink(std::sync::Weak<RunEntry>);
impl EventSink for RunSink {
fn emit(&self, event: Value) {
if let Some(entry) = self.0.upgrade() {
lock(&entry.events).push(event);
}
}
}
#[derive(Clone)]
struct ModelSeam {
engine: Arc<InferenceEngine>,
generator: Arc<dyn TurnGenerator>,
}
pub struct AssistantRunRegistry {
state: Arc<ServerState>,
runs: tokio::sync::Mutex<HashMap<String, Arc<RunEntry>>>,
slots: Arc<Semaphore>,
bounds: RunBounds,
model: Option<ModelSeam>,
trajectories: Option<PathBuf>,
base_ancestry: Vec<String>,
}
impl AssistantRunRegistry {
pub fn new(state: Arc<ServerState>) -> Arc<Self> {
let registry = Arc::new(Self {
state,
runs: tokio::sync::Mutex::new(HashMap::new()),
slots: Arc::new(Semaphore::new(MAX_OPEN_RUNS)),
bounds: RunBounds::default(),
model: None,
trajectories: Some(car_memgine::TrajectoryStore::default_path()),
base_ancestry: car_external_agents::recursion::ancestry(),
});
let weak = Arc::downgrade(®istry);
tokio::spawn(async move {
let mut ticker =
tokio::time::interval(std::time::Duration::from_secs(REAP_INTERVAL_SECS));
loop {
ticker.tick().await;
match weak.upgrade() {
Some(registry) => registry.reap_idle().await,
None => break,
}
}
});
registry
}
fn seam(&self) -> ModelSeam {
self.model.clone().unwrap_or_else(|| {
let engine = crate::handler::get_inference_engine(&self.state).clone();
ModelSeam {
generator: engine.clone(),
engine,
}
})
}
pub(crate) async fn reap_idle(&self) {
let stale: Vec<Arc<RunEntry>> = {
let runs = self.runs.lock().await;
runs.values()
.filter(|e| e.idle_secs() > self.bounds.idle_ttl_secs)
.cloned()
.collect()
};
for entry in stale {
tracing::info!(
run_id = %entry.id,
idle_secs = entry.idle_secs(),
"reaping an assistant run nobody has polled"
);
entry.abandon();
self.runs.lock().await.remove(&entry.id);
}
}
pub async fn start(&self, args: &Value) -> Result<Value, ToolError> {
let mut req = StartArgs::parse(args)?;
if !req.cwd.is_dir() {
return Err(refused(&format!(
"cwd is not a directory: {}. Pass the absolute path of the project the run \
should work in.",
req.cwd.display()
)));
}
self.reap_idle().await;
let slot = self.slots.clone().try_acquire_owned().map_err(|_| {
refused(&format!(
"{} assistant runs are already executing, which is the limit. Wait for one \
to reach a terminal status (assistant_poll) or stop one with \
assistant_cancel, then start again — starts are refused, never queued.",
self.bounds.max_open_runs
))
})?;
let env = bind_default_substrate(req.local, false, &req.cwd, None).await;
let posture = SandboxPosture {
sandboxed: env.sandboxed,
image: env.sandboxed.then(|| DEFAULT_ASSISTANT_IMAGE.to_string()),
tier: format!("{:?}", env.tier),
root: env.root.display().to_string(),
fallback_notice: env.fallback_notice.clone(),
};
if req.until.is_some() && matches!(env.tier, PermissionTier::ReadOnly) {
return Err(refused(&format!(
"`until` needs to run a shell command to decide completion, and this run \
bound at ReadOnly ({}), where shell is refused. Drop `until`, or start \
without `local` so the run gets a sandbox with a real shell.",
env.fallback_notice
.as_deref()
.unwrap_or("local: true was requested")
)));
}
let seam = self.seam();
let asm = build_assistant_runtime(
seam.engine.clone(),
env,
None,
None,
None,
self.trajectories.clone(),
)
.await
.map_err(|e| refused(&format!("could not assemble the assistant runtime: {e}")))?;
req.system = prompt::batch_prompt(&asm.description, &asm.tools);
let cfg = AssistantConfig {
model: req.model.clone(),
strict_model: false,
max_turns: req.max_turns,
tools: asm.tools.clone(),
gated_tools: asm.gated_tools.clone(),
approval_policy: None,
proactive_memory: Some(asm.proactive_memory.clone()),
tool_labels: None,
todos: Some(Arc::clone(&asm.todos)),
value_store_previews: false,
};
let id = format!("mcp-run-{}", uuid::Uuid::new_v4().simple());
let entry = Arc::new(RunEntry {
id: id.clone(),
ancestry: seed_ancestry_in(&self.base_ancestry, req.invoked_by.as_deref()),
created_at: now_secs(),
last_poll: AtomicU64::new(now_secs()),
outcome: StdMutex::new(RunOutcome::default()),
events: StdMutex::new(EventBuffer::new(self.bounds.event_buffer_max)),
cancel: Arc::new(AtomicBool::new(false)),
task: StdMutex::new(None),
});
self.runs.lock().await.insert(id.clone(), entry.clone());
let sandbox = posture.to_json();
let handle = tokio::spawn(run_task(
entry.clone(),
seam.generator,
asm.runtime,
cfg,
posture,
req,
slot,
));
*lock(&entry.task) = Some(handle);
Ok(json!({
"run_id": id,
"status": RunStatus::Running.as_str(),
"poll_after_ms": POLL_AFTER_MS,
"sandbox": sandbox,
"ancestry": entry.ancestry,
}))
}
pub async fn poll(&self, args: &Value) -> Result<Value, ToolError> {
let run_id = str_arg(args, "run_id")?.ok_or_else(|| missing("run_id"))?;
let since_seq = u64_arg(args, "since_seq")?.unwrap_or(0);
let entry = match self.runs.lock().await.get(&run_id).cloned() {
Some(e) => e,
None => {
return Err(refused(&format!(
"run not found: {run_id} — the daemon may have restarted. A run handle \
lives in memory and does not survive one. Start again with \
assistant_start."
)))
}
};
entry.touch();
entry.settle_if_task_died();
let (events, events_skipped, next_seq) = {
let buffer = lock(&entry.events);
let (events, skipped) = buffer.since(since_seq);
(events, skipped, buffer.next_seq)
};
let (status, doc) = {
let out = lock(&entry.outcome);
(out.status.unwrap_or(RunStatus::Running), out.doc.clone())
};
let mut result = json!({
"run_id": entry.id,
"status": status.as_str(),
"events": events,
"next_seq": next_seq,
"events_skipped": events_skipped,
"ancestry": entry.ancestry,
"created_at": entry.created_at,
});
match status {
RunStatus::Running => {
result["poll_after_ms"] = json!(POLL_AFTER_MS);
}
_ => {
if let Some(doc) = doc {
result["result"] = doc;
}
}
}
Ok(result)
}
pub async fn cancel(&self, args: &Value) -> Result<Value, ToolError> {
let run_id = str_arg(args, "run_id")?.ok_or_else(|| missing("run_id"))?;
let entry = self.runs.lock().await.get(&run_id).cloned();
let status = match entry {
None => "unknown",
Some(entry) if entry.status().is_terminal() => "already_terminal",
Some(entry) => {
entry.touch();
entry.request_cancel();
"cancelled"
}
};
Ok(json!({ "run_id": run_id, "status": status }))
}
}
#[allow(clippy::too_many_arguments)]
async fn run_task(
entry: Arc<RunEntry>,
generator: Arc<dyn TurnGenerator>,
runtime: car_engine::Runtime,
cfg: AssistantConfig,
posture: SandboxPosture,
req: StartArgs,
_slot: OwnedSemaphorePermit,
) {
let emitter = JsonEmitter::new(posture, Arc::new(RunSink(Arc::downgrade(&entry))));
emitter.started(&req.task, cfg.model.as_deref().unwrap_or("(router)"));
let description = req.system.clone();
let (outcome, goal) = match req.until.clone() {
None => {
let mut messages = vec![
Message::System {
content: description,
},
Message::User {
content: req.task.clone(),
},
];
let outcome = run_assistant_loop_cancellable(
generator.as_ref(),
&runtime,
&cfg,
&mut messages,
&entry.cancel,
None,
None,
|ev: AssistantEvent| emitter.on_assistant_event(&ev),
)
.await;
(outcome, None)
}
Some(check) => {
let (outcome, report) = goal_run(
&entry,
&emitter,
generator.as_ref(),
&runtime,
&cfg,
&req,
&check,
description,
)
.await;
(outcome, Some(report))
}
};
let cancelled = outcome.status == "cancelled";
let doc = emitter.finish(&outcome, goal.as_ref());
let status = if doc["status"] == "error" {
RunStatus::Error
} else if cancelled {
RunStatus::Cancelled
} else {
RunStatus::Ok
};
entry.settle(status, Some(doc));
}
#[allow(clippy::too_many_arguments)]
async fn goal_run(
entry: &Arc<RunEntry>,
emitter: &JsonEmitter,
generator: &dyn TurnGenerator,
runtime: &car_engine::Runtime,
cfg: &AssistantConfig,
req: &StartArgs,
check: &str,
system: String,
) -> (crate::assistant::AssistantOutcome, GoalReport) {
use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
let spec = GoalSpec {
goal: req.task.clone(),
condition: GoalCondition::Command {
id: "goal_check".into(),
expect_exit: 0,
},
governor: GoalGovernor {
max_turns: Some(GOAL_MAX_ITERATIONS),
..Default::default()
},
};
let mut messages = vec![Message::System {
content: format!(
"{system}\n\nYou are working toward a goal. Completion is verified \
deterministically by running this shell command:\n {check}\nIt is done \
only when that command exits 0. Keep working until it does."
),
}];
let result = run_assistant_goal_loop(
generator,
runtime,
cfg,
&mut messages,
&entry.cancel,
None,
&spec,
|_outcome| {
let cmd = check.to_string();
async move {
let exit = goal_check_exit(runtime, cfg, &cmd).await;
let mut g = car_engine::GoalGather::default();
g.command_exits.insert("goal_check".into(), exit);
g
}
},
|ev: AssistantEvent| emitter.on_assistant_event(&ev),
)
.await;
let report = GoalReport {
check: check.to_string(),
passed: matches!(result.run.status, GoalStatus::Achieved),
grounded: result.run.grounded,
iterations: result.run.iterations,
halt: match &result.run.status {
GoalStatus::Achieved => None,
GoalStatus::Halted { halt } => Some(halt.as_str().to_string()),
},
};
(result.outcome, report)
}
async fn goal_check_exit(
runtime: &car_engine::Runtime,
cfg: &AssistantConfig,
command: &str,
) -> i32 {
if cfg.gated_tools.iter().any(|tool| tool == "shell") {
return 1;
}
let proposal: car_ir::ActionProposal = serde_json::from_value(json!({
"source": "goal-check",
"actions": [{
"id": "goal_check",
"type": "tool_call",
"tool": "shell",
"parameters": { "command": command },
}],
}))
.expect("static shell-check proposal shape");
let exec = runtime.execute(&proposal).await;
exec.results
.first()
.and_then(|r| r.output.as_ref())
.and_then(|o| o.get("exit_code"))
.and_then(|v| v.as_i64())
.unwrap_or(1) as i32
}
#[derive(Clone)]
struct StartArgs {
task: String,
cwd: PathBuf,
until: Option<String>,
max_turns: u32,
local: bool,
model: Option<String>,
invoked_by: Option<String>,
system: String,
}
impl StartArgs {
fn parse(args: &Value) -> Result<Self, ToolError> {
let task = str_arg(args, "task")?
.filter(|t| !t.trim().is_empty())
.ok_or_else(|| missing("task"))?;
let cwd = match str_arg(args, "cwd")?.filter(|c| !c.trim().is_empty()) {
Some(c) => PathBuf::from(c),
None => std::env::current_dir().map_err(|e| {
refused(&format!(
"no cwd was given and the daemon's is unresolvable: {e}"
))
})?,
};
let max_turns = u64_arg(args, "max_turns")?
.map(|n| (n as u32).clamp(1, MAX_MAX_TURNS))
.unwrap_or(DEFAULT_MAX_TURNS);
Ok(Self {
task,
cwd,
until: str_arg(args, "until")?.filter(|c| !c.trim().is_empty()),
max_turns,
local: args.get("local").and_then(Value::as_bool).unwrap_or(false),
model: str_arg(args, "model")?.filter(|m| !m.trim().is_empty()),
invoked_by: str_arg(args, "invoked_by")?,
system: String::new(),
})
}
}
fn missing(field: &str) -> ToolError {
ToolError::InvalidParams(format!("missing {field}"))
}
fn refused(message: &str) -> ToolError {
ToolError::Internal(message.to_string())
}
fn str_arg(args: &Value, key: &str) -> Result<Option<String>, ToolError> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(ToolError::InvalidParams(format!("{key} must be a string"))),
}
}
fn u64_arg(args: &Value, key: &str) -> Result<Option<u64>, ToolError> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(Some).ok_or_else(|| {
ToolError::InvalidParams(format!("{key} must be a non-negative integer"))
}),
}
}
macro_rules! tool_handler {
($name:ident, $method:ident) => {
struct $name(Arc<AssistantRunRegistry>);
#[async_trait::async_trait]
impl ToolHandler for $name {
async fn call(&self, args: Value) -> Result<String, ToolError> {
let v = self.0.$method(&args).await?;
serde_json::to_string(&v).map_err(|e| ToolError::Internal(e.to_string()))
}
}
};
}
tool_handler!(StartTool, start);
tool_handler!(PollTool, poll);
tool_handler!(CancelTool, cancel);
pub fn register_assistant_tools(
server: &mut car_mcp::Server,
state: Arc<ServerState>,
) -> Result<(), RegisterError> {
let registry = AssistantRunRegistry::new(state);
server.register_tool(start_schema(), Arc::new(StartTool(registry.clone())))?;
server.register_tool(poll_schema(), Arc::new(PollTool(registry.clone())))?;
server.register_tool(cancel_schema(), Arc::new(CancelTool(registry)))?;
Ok(())
}
fn start_schema() -> Value {
json!({
"name": "assistant_start",
"description": "Start a CAR assistant run (the agent behind `car do`) and return a \
run handle immediately. Poll it with assistant_poll; stop it with \
assistant_cancel. A run takes minutes, so this never blocks. The \
handle lives in the daemon's memory and does NOT survive a daemon \
restart. By default the run executes in a Docker sandbox with no \
network; `local: true` runs on the host read-only — writes and \
shell are refused, because a tool call has no way to ask a human \
for approval. If your host is itself an agent CLI, set `invoked_by` \
to its adapter id (claude-code, codex, gemini) so the run records \
the invocation chain it is part of.",
"inputSchema": {
"type": "object",
"properties": {
"task": { "type": "string", "description": "What the assistant should do." },
"cwd": {
"type": "string",
"description": "Working directory for the run. Defaults to the daemon's, which is usually not your project.",
},
"until": {
"type": "string",
"description": "Goal mode: keep working until this shell command exits 0. Requires the sandbox (a local run cannot use shell).",
},
"max_turns": {
"type": "integer",
"minimum": 1,
"maximum": MAX_MAX_TURNS,
"description": "Safety cap on agent turns. Default 50.",
},
"local": {
"type": "boolean",
"description": "Run on the host instead of the sandbox. Read-only: writes and shell are refused.",
},
"model": { "type": "string", "description": "Pin a model. Default: CAR's router picks." },
"invoked_by": {
"type": "string",
"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`.",
},
},
"required": ["task"],
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": true,
},
})
}
fn poll_schema() -> Value {
json!({
"name": "assistant_poll",
"description": "Read progress from an assistant run. Returns events at or after \
`since_seq` plus `next_seq` to pass to the following poll. `status` \
is running | ok | error | cancelled and describes the HANDLE; once \
terminal, `result` carries the car.do/1 document (summary, turns, \
receipts, ungrounded_claims, sandbox) whose own `status` describes \
the WORK — success | max_turns | stalled | goal_pending | cancelled \
| error. `events_skipped` is non-zero when the buffer trimmed its \
head before you read it. Poll incrementally: a poll with \
`since_seq: 0` on a long run can return up to 2000 buffered events \
in one result, all of which land in your context. An unknown run_id \
means the run finished long ago or the daemon restarted.",
"inputSchema": {
"type": "object",
"properties": {
"run_id": { "type": "string" },
"since_seq": {
"type": "integer",
"minimum": 0,
"description": "First event seq to return. Use next_seq from the previous poll; 0 for the whole buffer.",
},
},
"required": ["run_id"],
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false,
},
})
}
fn cancel_schema() -> Value {
json!({
"name": "assistant_cancel",
"description": "Stop an assistant run. The run stops at its next TURN BOUNDARY, not \
mid-model-call, so expect one more turn's worth of activity — then \
poll for the car.do/1 document describing what it had done. Returns \
cancelled | already_terminal | unknown; cancelling a finished or \
unknown run is a successful no-op.",
"inputSchema": {
"type": "object",
"properties": { "run_id": { "type": "string" } },
"required": ["run_id"],
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use car_inference::{GenerateRequest, InferenceResult};
use std::sync::atomic::AtomicUsize;
impl AssistantRunRegistry {
fn for_test(state: Arc<ServerState>, bounds: RunBounds, model: ModelSeam) -> Arc<Self> {
Arc::new(Self {
state,
runs: tokio::sync::Mutex::new(HashMap::new()),
slots: Arc::new(Semaphore::new(bounds.max_open_runs)),
bounds,
model: Some(model),
trajectories: None,
base_ancestry: Vec::new(),
})
}
}
fn turn(text: &str, tool_calls: Value) -> InferenceResult {
serde_json::from_value(json!({
"text": text, "tool_calls": tool_calls,
"trace_id": "t", "model_used": "scripted", "latency_ms": 0,
}))
.expect("scripted InferenceResult shape")
}
fn calculate_turn(text: &str, expression: &str) -> InferenceResult {
turn(
text,
json!([{
"id": "c1",
"name": "calculate",
"arguments": { "expression": expression },
}]),
)
}
fn shell_turn(text: &str, command: &str) -> InferenceResult {
turn(
text,
json!([{
"id": "s1",
"name": "shell",
"arguments": { "command": command },
}]),
)
}
struct Panics;
#[async_trait]
impl TurnGenerator for Panics {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
panic!("scripted panic inside a run task");
}
}
struct Script {
turns: Vec<InferenceResult>,
cursor: AtomicUsize,
entered: Option<Arc<tokio::sync::Notify>>,
release: Option<Arc<tokio::sync::Notify>>,
}
impl Script {
fn new(turns: Vec<InferenceResult>) -> Arc<Self> {
Arc::new(Self {
turns,
cursor: AtomicUsize::new(0),
entered: None,
release: None,
})
}
fn gated(
turns: Vec<InferenceResult>,
entered: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
) -> Arc<Self> {
Arc::new(Self {
turns,
cursor: AtomicUsize::new(0),
entered: Some(entered),
release: Some(release),
})
}
}
#[async_trait]
impl TurnGenerator for Script {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
if let Some(entered) = &self.entered {
entered.notify_one();
}
if let Some(release) = &self.release {
release.notified().await;
}
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
self.turns
.get(i)
.cloned()
.ok_or_else(|| "script exhausted".to_string())
}
}
fn seam(root: &std::path::Path, generator: Arc<dyn TurnGenerator>) -> ModelSeam {
let mut cfg = car_inference::InferenceConfig::default();
cfg.models_dir = root.join("models");
ModelSeam {
engine: Arc::new(car_inference::InferenceEngine::new(cfg)),
generator,
}
}
fn state() -> (Arc<ServerState>, tempfile::TempDir) {
let journal = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
(state, journal)
}
fn start_args(cwd: &std::path::Path, task: &str) -> Value {
json!({ "task": task, "cwd": cwd.display().to_string(), "local": true })
}
async fn poll(registry: &AssistantRunRegistry, run_id: &str, since: u64) -> Value {
registry
.poll(&json!({ "run_id": run_id, "since_seq": since }))
.await
.expect("poll")
}
async fn await_terminal(registry: &AssistantRunRegistry, run_id: &str) -> Value {
for _ in 0..500 {
let v = poll(registry, run_id, 0).await;
if v["status"] != "running" {
return v;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("run {run_id} never reached a terminal status");
}
#[tokio::test]
async fn start_poll_and_cancel_round_trip() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let registry = AssistantRunRegistry::for_test(
state,
RunBounds::default(),
seam(dir.path(), Script::new(vec![turn("all done", json!([]))])),
);
let started = registry
.start(&start_args(dir.path(), "say you are done"))
.await
.expect("start");
let run_id = started["run_id"].as_str().expect("run_id").to_string();
assert!(run_id.starts_with("mcp-run-"), "{run_id}");
assert_eq!(started["status"], "running");
assert_eq!(started["poll_after_ms"], POLL_AFTER_MS);
let done = await_terminal(®istry, &run_id).await;
assert_eq!(done["status"], "ok", "{done}");
assert_eq!(done["events_skipped"], 0);
assert_eq!(done["result"]["schema"], "car.do/1");
assert_eq!(done["result"]["summary"], "all done");
assert!(done["result"]["receipts"]["total"].is_number(), "{done}");
let events = done["events"].as_array().expect("events");
assert_eq!(events[0]["type"], "started");
assert_eq!(events[0]["seq"], 0);
assert!(
events.iter().any(|e| e["type"] == "completed"),
"{events:?}"
);
assert_eq!(done["next_seq"], events.len() as u64);
let tail = poll(®istry, &run_id, done["next_seq"].as_u64().unwrap()).await;
assert!(tail["events"].as_array().unwrap().is_empty(), "{tail}");
}
#[tokio::test]
async fn cancel_lands_at_the_next_turn_boundary() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let registry = AssistantRunRegistry::for_test(
state,
RunBounds::default(),
seam(
dir.path(),
Script::gated(
vec![
calculate_turn("working", "1 + 1"),
turn("never reached", json!([])),
],
entered.clone(),
release.clone(),
),
),
);
let started = registry
.start(&start_args(dir.path(), "keep going"))
.await
.expect("start");
let run_id = started["run_id"].as_str().unwrap().to_string();
entered.notified().await;
let cancelled = registry
.cancel(&json!({ "run_id": run_id }))
.await
.expect("cancel");
assert_eq!(cancelled["status"], "cancelled");
release.notify_one();
let done = await_terminal(®istry, &run_id).await;
assert_eq!(done["status"], "cancelled", "{done}");
assert_eq!(done["result"]["status"], "cancelled");
assert_eq!(done["result"]["receipts"]["total"], 1);
let again = registry
.cancel(&json!({ "run_id": run_id }))
.await
.expect("cancel again");
assert_eq!(again["status"], "already_terminal");
}
#[tokio::test]
async fn a_start_past_the_cap_is_refused_and_creates_no_run() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let bounds = RunBounds {
max_open_runs: 2,
..RunBounds::default()
};
let registry = AssistantRunRegistry::for_test(
state,
bounds,
seam(
dir.path(),
Script::gated(
vec![turn("done", json!([]))],
entered.clone(),
release.clone(),
),
),
);
for _ in 0..2 {
registry
.start(&start_args(dir.path(), "hold a slot"))
.await
.expect("start within the cap");
}
let refused = registry
.start(&start_args(dir.path(), "one too many"))
.await
.expect_err("past the cap");
assert!(refused.is_execution_error());
let message = refused.message().to_string();
assert!(message.contains('2'), "the cap must be named: {message}");
assert!(
message.contains("assistant_cancel"),
"the way out must be named: {message}"
);
assert_eq!(registry.runs.lock().await.len(), 2, "no run was created");
}
#[tokio::test]
async fn a_finished_run_does_not_keep_holding_its_slot() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let bounds = RunBounds {
max_open_runs: 1,
..RunBounds::default()
};
let registry = AssistantRunRegistry::for_test(
state,
bounds,
seam(
dir.path(),
Script::new(vec![turn("first", json!([])), turn("second", json!([]))]),
),
);
let first = registry
.start(&start_args(dir.path(), "the first run"))
.await
.expect("start");
let run_id = first["run_id"].as_str().unwrap().to_string();
await_terminal(®istry, &run_id).await;
assert_eq!(registry.runs.lock().await.len(), 1);
for attempt in 0..100 {
if registry
.start(&start_args(dir.path(), "the second run"))
.await
.is_ok()
{
return;
}
assert!(attempt < 99, "a finished run never released its slot");
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn a_run_nobody_polls_is_reaped_and_its_slot_returned() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let bounds = RunBounds {
max_open_runs: 1,
..RunBounds::default()
};
let registry = AssistantRunRegistry::for_test(
state,
bounds,
seam(
dir.path(),
Script::gated(
vec![turn("done", json!([]))],
entered.clone(),
release.clone(),
),
),
);
let started = registry
.start(&start_args(dir.path(), "abandon me"))
.await
.expect("start");
let run_id = started["run_id"].as_str().unwrap().to_string();
entered.notified().await;
registry.runs.lock().await[&run_id]
.last_poll
.store(now_secs() - RUN_IDLE_TTL_SECS - 1, Ordering::SeqCst);
registry.reap_idle().await;
assert!(registry.runs.lock().await.is_empty());
let err = registry
.poll(&json!({ "run_id": run_id }))
.await
.expect_err("reaped");
assert!(err.message().contains("run not found"), "{}", err.message());
for attempt in 0..100 {
if registry
.start(&start_args(dir.path(), "the next run"))
.await
.is_ok()
{
return;
}
assert!(attempt < 99, "the reaped run never released its slot");
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn a_trimmed_event_buffer_states_the_gap() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let bounds = RunBounds {
event_buffer_max: 4,
..RunBounds::default()
};
let registry = AssistantRunRegistry::for_test(
state,
bounds,
seam(
dir.path(),
Script::new(vec![
calculate_turn("step one", "1 + 1"),
calculate_turn("step two", "2 + 2"),
calculate_turn("step three", "3 + 3"),
turn("finished", json!([])),
]),
),
);
let started = registry
.start(&start_args(dir.path(), "do several things"))
.await
.expect("start");
let run_id = started["run_id"].as_str().unwrap().to_string();
let done = await_terminal(®istry, &run_id).await;
let events = done["events"].as_array().expect("events");
assert!(
events.len() <= 4,
"buffer was not trimmed: {}",
events.len()
);
assert!(
done["events_skipped"].as_u64().unwrap() > 0,
"a trimmed head must be stated, not silent: {done}"
);
assert!(events[0]["seq"].as_u64().unwrap() > 0, "{events:?}");
assert_eq!(
done["events_skipped"].as_u64().unwrap(),
events[0]["seq"].as_u64().unwrap()
);
}
#[tokio::test]
async fn a_caller_that_names_itself_gets_an_ancestry_and_one_that_does_not_gets_none() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let registry = AssistantRunRegistry::for_test(
state,
RunBounds::default(),
seam(dir.path(), Script::new(vec![turn("done", json!([]))])),
);
let mut args = start_args(dir.path(), "run under a host");
args["invoked_by"] = json!("Claude-Code");
let named = registry.start(&args).await.expect("start");
assert_eq!(named["ancestry"], json!(["claude-code"]));
let run_id = named["run_id"].as_str().unwrap().to_string();
assert_eq!(
await_terminal(®istry, &run_id).await["ancestry"],
json!(["claude-code"])
);
let anonymous = registry
.start(&start_args(dir.path(), "run from nowhere"))
.await
.expect("start");
assert_eq!(anonymous["ancestry"], json!([]));
}
#[tokio::test]
async fn a_local_run_refuses_shell_and_still_settles() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let registry = AssistantRunRegistry::for_test(
state,
RunBounds::default(),
seam(
dir.path(),
Script::new(vec![
shell_turn("let me look around", "true"),
turn("could not run that", json!([])),
]),
),
);
let started = registry
.start(&start_args(dir.path(), "run a shell command"))
.await
.expect("start");
assert_eq!(started["sandbox"]["tier"], "ReadOnly", "{started}");
let run_id = started["run_id"].as_str().unwrap().to_string();
let done = await_terminal(®istry, &run_id).await;
assert_eq!(done["status"], "ok", "{done}");
assert_eq!(done["result"]["schema"], "car.do/1");
let events = done["events"].as_array().expect("events");
assert!(
events
.iter()
.any(|e| e["type"] == "tool_failed" && e["data"]["tool"] == "shell"),
"shell was not refused: {events:?}"
);
assert!(
!events
.iter()
.any(|e| e["type"] == "tool_result" && e["data"]["tool"] == "shell"),
"shell RAN on a local: true run: {events:?}"
);
assert_eq!(done["result"]["receipts"]["total"], 0, "{done}");
}
#[tokio::test]
async fn a_run_whose_task_panics_settles_instead_of_polling_forever() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let registry = AssistantRunRegistry::for_test(
state,
RunBounds::default(),
seam(dir.path(), Arc::new(Panics)),
);
let started = registry
.start(&start_args(dir.path(), "die mid-run"))
.await
.expect("start");
let run_id = started["run_id"].as_str().unwrap().to_string();
let done = await_terminal(®istry, &run_id).await;
assert_eq!(done["status"], "error", "{done}");
assert_eq!(done["result"]["schema"], "car.do/1");
assert_eq!(done["result"]["error"], "run_task_died", "{done}");
let again = poll(®istry, &run_id, 0).await;
assert_eq!(again["status"], "error", "{again}");
}
#[tokio::test]
async fn a_missing_task_is_a_protocol_error_not_a_refusal() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let registry = AssistantRunRegistry::for_test(
state,
RunBounds::default(),
seam(dir.path(), Script::new(vec![])),
);
let e = registry
.start(&json!({ "cwd": "." }))
.await
.expect_err("no task");
assert!(!e.is_execution_error());
assert!(e.message().contains("task"), "{}", e.message());
}
#[tokio::test]
async fn the_daemon_server_advertises_all_three() {
let (state, _journal) = state();
let mut server = car_mcp::Server::new();
register_assistant_tools(&mut server, state).expect("registers");
let resp = server
.handle(
serde_json::from_value(json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {},
}))
.expect("request"),
)
.await
.expect("response");
let names: Vec<String> = resp.result.expect("result")["tools"]
.as_array()
.expect("array")
.iter()
.map(|t| t["name"].as_str().expect("name").to_string())
.collect();
for tool in ["assistant_start", "assistant_poll", "assistant_cancel"] {
assert!(names.iter().any(|n| n == tool), "{tool} missing: {names:?}");
}
}
}