use std::sync::Arc;
use async_trait::async_trait;
use car_fleet::{DispatchOutcome, RepoFingerprint, SubtaskDispatch};
use car_multi::{AgentRunSummary, ForemanError, WorktreeAgent, WorktreeAgentRequest};
pub struct RemoteWorktreeAgent {
pub peer_name: String,
base_url: String,
identity: Option<Arc<car_a2a::peer_auth::PeerIdentity>>,
repo: RepoFingerprint,
run_id: String,
adapter: Option<String>,
timeout_secs: Option<u64>,
}
impl RemoteWorktreeAgent {
pub fn new(
peer_name: impl Into<String>,
base_url: impl Into<String>,
identity: Option<Arc<car_a2a::peer_auth::PeerIdentity>>,
repo: RepoFingerprint,
run_id: impl Into<String>,
) -> Self {
Self {
peer_name: peer_name.into(),
base_url: base_url.into(),
identity,
repo,
run_id: run_id.into(),
adapter: None,
timeout_secs: None,
}
}
pub fn with_adapter(mut self, adapter: Option<String>) -> Self {
self.adapter = adapter;
self
}
pub fn with_timeout_secs(mut self, timeout_secs: Option<u64>) -> Self {
self.timeout_secs = timeout_secs;
self
}
}
#[async_trait]
impl WorktreeAgent for RemoteWorktreeAgent {
async fn run_in(
&self,
req: &WorktreeAgentRequest<'_>,
) -> Result<AgentRunSummary, ForemanError> {
let identity = self.identity.clone().ok_or_else(|| {
ForemanError::Agent(format!(
"cannot reach `{}`: this daemon has no peer identity, so a remote CAR would \
refuse it. The identity is created when the A2A surface starts.",
self.peer_name
))
})?;
let mut dispatch = SubtaskDispatch::new(
self.run_id.clone(),
req.subtask.id.clone(),
req.subtask.prompt.clone(),
self.repo.clone(),
);
dispatch.files = req.subtask.files.clone();
dispatch.allowed_tools = req.allowed_tools.clone();
dispatch.adapter = self.adapter.clone();
dispatch.timeout_secs = self.timeout_secs;
let params = serde_json::to_value(&dispatch)
.map_err(|e| ForemanError::Agent(format!("encode dispatch: {e}")))?;
let client = car_a2a::client::A2aClient::new(&self.base_url)
.with_http_client(dispatch_client(self.timeout_secs))
.with_peer_identity(identity);
let mut outcome = self.dispatch_once(&client, ¶ms).await?;
if retry_after_decline(&outcome) {
tokio::time::sleep(RETRY_DELAY).await;
outcome = self.dispatch_once(&client, ¶ms).await?;
}
match outcome {
DispatchOutcome::Declined { reason, detail } => {
let text = format!(
"`{}` declined ({}): {detail}",
self.peer_name,
reason.as_str()
);
Err(if decline_is_worker_level(reason) {
ForemanError::Worker(text)
} else {
ForemanError::Agent(text)
})
}
DispatchOutcome::Completed { patch, answer, .. } => {
if patch.trim().is_empty() {
return Ok(AgentRunSummary { answer });
}
car_multi::git_apply(req.cwd, &patch).map_err(|e| {
ForemanError::Agent(format!(
"`{}` returned a patch that does not apply to the base it was given: {e}",
self.peer_name
))
})?;
Ok(AgentRunSummary { answer })
}
}
}
}
fn decline_is_worker_level(reason: car_fleet::DeclineReason) -> bool {
use car_fleet::DeclineReason as R;
match reason {
R::NotAcceptingWork => true,
R::RepoUnavailable | R::CommitUnavailable => true,
R::AdapterUnavailable => true,
R::RateLimited => true,
R::Busy => false,
R::PolicyDenied => false,
}
}
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const PATCH_TRANSFER_SLACK: std::time::Duration = std::time::Duration::from_secs(60);
fn dispatch_deadline(timeout_secs: Option<u64>) -> std::time::Duration {
let budget = timeout_secs.unwrap_or(super::DEFAULT_MAX_SUBTASK_SECS);
std::time::Duration::from_secs(budget) + PATCH_TRANSFER_SLACK
}
fn dispatch_client(timeout_secs: Option<u64>) -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(dispatch_deadline(timeout_secs))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
fn call_error_is_worker_level(err: &car_a2a::client::ClientError) -> bool {
match err {
car_a2a::client::ClientError::Transport(e) => e.is_connect(),
car_a2a::client::ClientError::Status { .. }
| car_a2a::client::ClientError::Serialize(_)
| car_a2a::client::ClientError::Rpc { .. }
| car_a2a::client::ClientError::BadResultShape(_)
| car_a2a::client::ClientError::Malformed(_) => false,
}
}
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(2);
fn retry_after_decline(outcome: &DispatchOutcome) -> bool {
match outcome {
DispatchOutcome::Declined { reason, .. } => {
matches!(reason.retry_window(), car_fleet::RetryWindow::Immediately)
}
DispatchOutcome::Completed { .. } => false,
}
}
impl RemoteWorktreeAgent {
async fn dispatch_once(
&self,
client: &car_a2a::client::A2aClient,
params: &serde_json::Value,
) -> Result<DispatchOutcome, ForemanError> {
let raw: serde_json::Value =
client
.call("car/foremanSubtask", params)
.await
.map_err(|e| {
let text = format!("`{}` did not run the subtask: {e}", self.peer_name);
if call_error_is_worker_level(&e) {
ForemanError::Worker(text)
} else {
ForemanError::Agent(text)
}
})?;
serde_json::from_value(raw).map_err(|e| {
ForemanError::Agent(format!("`{}` answered unusably: {e}", self.peer_name))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_fleet::DeclineReason;
use car_multi::Subtask;
fn fingerprint() -> RepoFingerprint {
RepoFingerprint {
root_commit: "root".into(),
head_commit: "head".into(),
name: Some("car".into()),
}
}
#[tokio::test]
async fn without_a_peer_identity_it_says_so_locally() {
let agent =
RemoteWorktreeAgent::new("studio", "http://studio:8731", None, fingerprint(), "run-1");
let subtask = Subtask::files_only("s", "do it", vec![]);
let cwd = std::path::PathBuf::from(".");
let req = WorktreeAgentRequest {
subtask: &subtask,
cwd: &cwd,
allowed_tools: None,
mcp_endpoint: None,
mcp_config_dir: None,
};
let err = agent.run_in(&req).await.unwrap_err();
assert!(err.to_string().contains("peer identity"), "{err}");
}
#[test]
fn only_a_full_peer_is_offered_the_subtask_twice() {
let declined = |reason| DispatchOutcome::Declined {
reason,
detail: String::new(),
};
assert!(retry_after_decline(&declined(DeclineReason::Busy)));
for reason in [
DeclineReason::RateLimited,
DeclineReason::NotAcceptingWork,
DeclineReason::RepoUnavailable,
DeclineReason::CommitUnavailable,
DeclineReason::AdapterUnavailable,
DeclineReason::PolicyDenied,
] {
assert!(
!retry_after_decline(&declined(reason)),
"{reason:?} would decline identically on a retry"
);
}
assert!(!retry_after_decline(&DispatchOutcome::Completed {
patch: String::new(),
answer: String::new(),
adapter: "claude-code".into(),
duration_ms: 1,
}));
}
#[test]
fn only_a_momentarily_full_peer_stays_in_the_pool() {
for reason in [
DeclineReason::NotAcceptingWork,
DeclineReason::RepoUnavailable,
DeclineReason::CommitUnavailable,
DeclineReason::AdapterUnavailable,
DeclineReason::RateLimited,
] {
assert!(
decline_is_worker_level(reason),
"{reason:?} answers every subtask of this run identically"
);
}
assert!(!decline_is_worker_level(DeclineReason::Busy));
}
#[test]
fn an_unwired_decline_reason_does_not_evict_the_peer() {
assert!(!decline_is_worker_level(DeclineReason::PolicyDenied));
}
#[tokio::test]
async fn reqwest_tells_a_dead_peer_from_a_slow_one() {
let dead_port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let refused = reqwest::Client::new()
.post(format!("http://127.0.0.1:{dead_port}/"))
.send()
.await
.expect_err("nothing is listening");
assert!(refused.is_connect(), "a refused connection is a dead peer");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let busy_port = listener.local_addr().unwrap().port();
let held = tokio::spawn(async move {
let _accepted = listener.accept().await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
});
let slow = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(300))
.build()
.unwrap()
.post(format!("http://127.0.0.1:{busy_port}/"))
.send()
.await
.expect_err("the deadline passes before an answer");
assert!(slow.is_timeout(), "it is a timeout");
assert!(
!slow.is_connect(),
"but NOT a connect failure — the peer is there and working"
);
held.abort();
}
#[test]
fn the_dispatch_deadline_covers_the_peer_s_whole_budget() {
let default = dispatch_deadline(None);
assert!(
default >= std::time::Duration::from_secs(super::super::DEFAULT_MAX_SUBTASK_SECS),
"hanging up before the peer's own ceiling aborts work that is still running"
);
assert!(
default > std::time::Duration::from_secs(30),
"the A2aClient default is what this exists to replace"
);
assert_eq!(
dispatch_deadline(Some(60)),
std::time::Duration::from_secs(60) + PATCH_TRANSFER_SLACK
);
assert!(CONNECT_TIMEOUT < default);
}
#[tokio::test]
async fn a_peer_that_is_gone_is_worker_level_and_one_that_answers_badly_is_not() {
use car_a2a::client::A2aClient;
let dead_port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let err = A2aClient::new(format!("http://127.0.0.1:{dead_port}"))
.call::<_, serde_json::Value>("car/foremanSubtask", &serde_json::json!({}))
.await
.expect_err("nothing is listening");
assert!(
call_error_is_worker_level(&err),
"connect refused means the machine is not there: {err}"
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let live_port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
use tokio::io::AsyncWriteExt;
let _ = sock
.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
content-length: 8\r\n\r\nnot json",
)
.await;
let _ = sock.flush().await;
}
});
let err = A2aClient::new(format!("http://127.0.0.1:{live_port}"))
.call::<_, serde_json::Value>("car/foremanSubtask", &serde_json::json!({}))
.await
.expect_err("the body does not parse");
assert!(
!call_error_is_worker_level(&err),
"a peer that ANSWERED is alive, however unusably: {err}"
);
}
}