car_server_core/fleet/remote.rs
1//! The orchestrator side: a [`car_multi::WorktreeAgent`] backed by a peer.
2//!
3//! From the harness's point of view this is indistinguishable from the local
4//! `ForemanExternalAgent` — it is handed a worktree and a subtask, and when it
5//! returns the worktree contains the edits. The difference is only where the
6//! editing happened: the prompt goes to a peer, the peer runs its own coding
7//! CLI in its own worktree at the same base commit, and the patch it returns is
8//! applied here.
9//!
10//! Everything after that is unchanged and local: patch capture, AST containment,
11//! duplicate-declaration detection, the build/test leg, the policy consult, and
12//! the union integration. A peer cannot widen what gets accepted; it can only
13//! produce edits that this host then judges.
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use car_fleet::{DispatchOutcome, RepoFingerprint, SubtaskDispatch};
19use car_multi::{AgentRunSummary, ForemanError, WorktreeAgent, WorktreeAgentRequest};
20
21/// Runs Foreman subtasks on one peer CAR instance.
22pub struct RemoteWorktreeAgent {
23 /// Peer name as this host's peer listing names it. Appears in the placement
24 /// ledger and in every error, so an operator can tell which machine dropped
25 /// a subtask.
26 pub peer_name: String,
27 base_url: String,
28 identity: Option<Arc<car_a2a::peer_auth::PeerIdentity>>,
29 repo: RepoFingerprint,
30 run_id: String,
31 adapter: Option<String>,
32 timeout_secs: Option<u64>,
33}
34
35impl RemoteWorktreeAgent {
36 pub fn new(
37 peer_name: impl Into<String>,
38 base_url: impl Into<String>,
39 identity: Option<Arc<car_a2a::peer_auth::PeerIdentity>>,
40 repo: RepoFingerprint,
41 run_id: impl Into<String>,
42 ) -> Self {
43 Self {
44 peer_name: peer_name.into(),
45 base_url: base_url.into(),
46 identity,
47 repo,
48 run_id: run_id.into(),
49 adapter: None,
50 timeout_secs: None,
51 }
52 }
53
54 /// Ask the peer for a specific coding CLI. A peer that does not have it
55 /// declines rather than substituting — see `car_fleet::worker`.
56 pub fn with_adapter(mut self, adapter: Option<String>) -> Self {
57 self.adapter = adapter;
58 self
59 }
60
61 /// Wall-clock bound the peer applies to its CLI invocation.
62 pub fn with_timeout_secs(mut self, timeout_secs: Option<u64>) -> Self {
63 self.timeout_secs = timeout_secs;
64 self
65 }
66}
67
68#[async_trait]
69impl WorktreeAgent for RemoteWorktreeAgent {
70 async fn run_in(
71 &self,
72 req: &WorktreeAgentRequest<'_>,
73 ) -> Result<AgentRunSummary, ForemanError> {
74 let identity = self.identity.clone().ok_or_else(|| {
75 ForemanError::Agent(format!(
76 "cannot reach `{}`: this daemon has no peer identity, so a remote CAR would \
77 refuse it. The identity is created when the A2A surface starts.",
78 self.peer_name
79 ))
80 })?;
81
82 let mut dispatch = SubtaskDispatch::new(
83 self.run_id.clone(),
84 req.subtask.id.clone(),
85 req.subtask.prompt.clone(),
86 self.repo.clone(),
87 );
88 dispatch.files = req.subtask.files.clone();
89 dispatch.allowed_tools = req.allowed_tools.clone();
90 dispatch.adapter = self.adapter.clone();
91 dispatch.timeout_secs = self.timeout_secs;
92
93 let params = serde_json::to_value(&dispatch)
94 .map_err(|e| ForemanError::Agent(format!("encode dispatch: {e}")))?;
95 let client = car_a2a::client::A2aClient::new(&self.base_url)
96 .with_http_client(dispatch_client(self.timeout_secs))
97 .with_peer_identity(identity);
98
99 // A peer at capacity is momentarily full, not the wrong machine — and
100 // this host's view of its capacity is always slightly stale, since other
101 // orchestrators are spending it too. So a transient decline gets one
102 // more chance before the pool moves on and stops considering this peer
103 // for the subtask. Everything else (wrong repo, no CLI, not enrolled)
104 // would fail identically on a retry, so it does not get one.
105 let mut outcome = self.dispatch_once(&client, ¶ms).await?;
106 if retry_after_decline(&outcome) {
107 tokio::time::sleep(RETRY_DELAY).await;
108 outcome = self.dispatch_once(&client, ¶ms).await?;
109 }
110
111 match outcome {
112 DispatchOutcome::Declined { reason, detail } => {
113 let text = format!(
114 "`{}` declined ({}): {detail}",
115 self.peer_name,
116 reason.as_str()
117 );
118 Err(if decline_is_worker_level(reason) {
119 ForemanError::Worker(text)
120 } else {
121 ForemanError::Agent(text)
122 })
123 }
124 DispatchOutcome::Completed { patch, answer, .. } => {
125 if patch.trim().is_empty() {
126 // A no-op is a real answer, and the gate already knows what
127 // to do with an unchanged worktree. Applying an empty patch
128 // would be an error for no reason.
129 return Ok(AgentRunSummary { answer });
130 }
131 car_multi::git_apply(req.cwd, &patch).map_err(|e| {
132 ForemanError::Agent(format!(
133 "`{}` returned a patch that does not apply to the base it was given: {e}",
134 self.peer_name
135 ))
136 })?;
137 Ok(AgentRunSummary { answer })
138 }
139 }
140 }
141}
142
143/// Whether a decline is a fact about the PEER rather than about the subtask.
144///
145/// EXHAUSTIVE on `DeclineReason`, deliberately, rather than folding through
146/// `retry_window()`. The two questions look alike and are not: that one asks
147/// whether the SAME SUBTASK is worth re-offering, this one asks whether ANY
148/// remaining subtask is. Answering the second with the first's table means a new
149/// variant silently inherits a classification nobody chose — and `PolicyDenied`
150/// is already that variant, mapped to `Never` (right, for a retry) with no
151/// producer anywhere in the workspace. Whoever wires a policy consult into
152/// `run_dispatch` would get whole-run eviction for free, with no compile signal.
153/// Two exhaustive matches cannot drift; one table answering an unasked question
154/// is exactly what does.
155///
156/// The `true` arms are the ones whose inputs are fixed for the whole run —
157/// enrollment and parallelism from the peer's `FleetWorkerConfig`, the repo and
158/// base commit from the one `RepoFingerprint` the agent is built with, the
159/// adapter from `with_adapter`, the hourly budget keyed on the caller. None of
160/// them read the prompt or the files, so a different subtask gets the identical
161/// answer.
162fn decline_is_worker_level(reason: car_fleet::DeclineReason) -> bool {
163 use car_fleet::DeclineReason as R;
164 match reason {
165 // The peer is not enrolled as a worker at all.
166 R::NotAcceptingWork => true,
167 // It does not have this repository, or this base commit. Both are the
168 // run's, not the subtask's.
169 R::RepoUnavailable | R::CommitUnavailable => true,
170 // No installed coding CLI could run it. The adapter is chosen once, when
171 // the agent is built.
172 R::AdapterUnavailable => true,
173 // An hourly budget, charged against the caller. `RetryWindow` calls this
174 // "could serve this later, but not on the timescale of a run" — which is
175 // the run, for this worker.
176 R::RateLimited => true,
177 // Momentary capacity. The peer is healthy and `run_in` already retries
178 // it once; evicting here would drop a working machine for being busy,
179 // which is the normal state of a working fleet.
180 R::Busy => false,
181 // No producer today. When one arrives it will most plausibly consult
182 // policy against the subtask's files, tools, or parameters — per-subtask
183 // by nature. Unknown classifies as subtask-level: the cost of guessing
184 // wrong here is dropping a healthy peer, and this arm is where the next
185 // author is forced to look.
186 R::PolicyDenied => false,
187 }
188}
189
190/// How long to wait to REACH a peer. Not how long it may take to answer.
191const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
192
193/// Headroom over the peer's own work budget for returning the patch.
194const PATCH_TRANSFER_SLACK: std::time::Duration = std::time::Duration::from_secs(60);
195
196/// How long the far side may take to answer one dispatch.
197///
198/// `A2aClient`'s default is a 30-second TOTAL request deadline — right for the
199/// small RPCs the rest of the A2A surface makes, and catastrophically wrong
200/// here: the peer runs the coding CLI INSIDE this request and is allowed
201/// `max_subtask_secs`, half an hour by default, to finish. Thirty seconds
202/// against a thirty-minute budget means the orchestrator hangs up on a peer that
203/// is still editing, for every subtask worth farming out.
204///
205/// The dispatch carries no negotiated budget — `SubtaskDispatch::timeout_secs`
206/// is `None` unless a caller sets one — so this falls back to the same constant
207/// the peer clamps to, and adds slack for the patch coming back over the wire.
208fn dispatch_deadline(timeout_secs: Option<u64>) -> std::time::Duration {
209 let budget = timeout_secs.unwrap_or(super::DEFAULT_MAX_SUBTASK_SECS);
210 std::time::Duration::from_secs(budget) + PATCH_TRANSFER_SLACK
211}
212
213/// The HTTP client one farmed-out subtask is dispatched on.
214///
215/// Two deadlines, because they answer different questions. `connect_timeout`
216/// bounds REACHING the machine and is short — a CAR peer is on a LAN or a known
217/// address, five seconds is generous for a handshake, and it is what makes a
218/// powered-off peer fail fast instead of consuming the whole request deadline.
219/// `timeout` bounds the WORK; see [`dispatch_deadline`].
220///
221/// That split is also what lets [`call_error_is_worker_level`] tell a dead
222/// machine from a slow one at all — collapsed into one deadline, both arrive as
223/// a timeout and are indistinguishable.
224fn dispatch_client(timeout_secs: Option<u64>) -> reqwest::Client {
225 reqwest::Client::builder()
226 .connect_timeout(CONNECT_TIMEOUT)
227 .timeout(dispatch_deadline(timeout_secs))
228 .build()
229 // Same posture as `A2aClient::new`: this configuration is static and
230 // valid, and a caller has nothing useful to do with the failure.
231 .unwrap_or_else(|_| reqwest::Client::new())
232}
233
234/// Whether a failed `car/foremanSubtask` call means the peer is unreachable.
235///
236/// Narrow twice over.
237///
238/// `ClientError::Transport` wraps ANY `reqwest::Error`, including the body-decode
239/// failure from `resp.json()`, so matching the variant would quarantine a live
240/// peer that answered non-JSON.
241///
242/// And within that, `is_connect()` ALONE — not `is_timeout()`. Measured, not
243/// assumed (`reqwest_tells_a_dead_peer_from_a_slow_one`): a refused connection
244/// and a connect-phase timeout both set `is_connect()`, while a peer that
245/// accepted the connection and is still working sets only `is_timeout()`. Since
246/// the peer runs the coding CLI inside this request, that last case is the
247/// COMMON one, and treating it as death would quarantine the whole fleet on the
248/// first subtask that took more than a moment.
249///
250/// Everything else — an HTTP status, a JSON-RPC error envelope, a shape this
251/// host cannot parse — is the peer ANSWERING. It may well be misconfigured or
252/// version-skewed, but that is a claim this function cannot make from one reply,
253/// and the cost of being wrong is dropping a healthy worker.
254fn call_error_is_worker_level(err: &car_a2a::client::ClientError) -> bool {
255 match err {
256 car_a2a::client::ClientError::Transport(e) => e.is_connect(),
257 car_a2a::client::ClientError::Status { .. }
258 | car_a2a::client::ClientError::Serialize(_)
259 | car_a2a::client::ClientError::Rpc { .. }
260 | car_a2a::client::ClientError::BadResultShape(_)
261 | car_a2a::client::ClientError::Malformed(_) => false,
262 }
263}
264
265/// Wait before re-offering a subtask to a peer that was full.
266///
267/// Short: the pool has other workers, and the point is to catch a peer that was
268/// finishing something, not to wait out a queue.
269const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(2);
270
271/// Whether an outcome is worth one immediate re-dispatch to the same peer.
272///
273/// Pure so the policy is testable without a peer: the interesting property is
274/// that exactly one decline reason (congestion) is retried and every other —
275/// each of which is a fact about that machine's configuration or checkout —
276/// is not.
277fn retry_after_decline(outcome: &DispatchOutcome) -> bool {
278 match outcome {
279 DispatchOutcome::Declined { reason, .. } => {
280 // Only congestion. A rate-limited peer *could* serve this later, but
281 // not on the timescale of a run — re-offering it two seconds later
282 // buys the identical answer a round trip after the first one.
283 matches!(reason.retry_window(), car_fleet::RetryWindow::Immediately)
284 }
285 DispatchOutcome::Completed { .. } => false,
286 }
287}
288
289impl RemoteWorktreeAgent {
290 /// One `car/foremanSubtask` round trip, decoded.
291 async fn dispatch_once(
292 &self,
293 client: &car_a2a::client::A2aClient,
294 params: &serde_json::Value,
295 ) -> Result<DispatchOutcome, ForemanError> {
296 let raw: serde_json::Value =
297 client
298 .call("car/foremanSubtask", params)
299 .await
300 .map_err(|e| {
301 let text = format!("`{}` did not run the subtask: {e}", self.peer_name);
302 if call_error_is_worker_level(&e) {
303 ForemanError::Worker(text)
304 } else {
305 ForemanError::Agent(text)
306 }
307 })?;
308 serde_json::from_value(raw).map_err(|e| {
309 ForemanError::Agent(format!("`{}` answered unusably: {e}", self.peer_name))
310 })
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use car_fleet::DeclineReason;
318 use car_multi::Subtask;
319
320 fn fingerprint() -> RepoFingerprint {
321 RepoFingerprint {
322 root_commit: "root".into(),
323 head_commit: "head".into(),
324 name: Some("car".into()),
325 }
326 }
327
328 #[tokio::test]
329 async fn without_a_peer_identity_it_says_so_locally() {
330 // The far side would answer 401 and the operator would go looking at
331 // the wrong machine. The fix is local, so the message is too.
332 let agent =
333 RemoteWorktreeAgent::new("studio", "http://studio:8731", None, fingerprint(), "run-1");
334 let subtask = Subtask::files_only("s", "do it", vec![]);
335 let cwd = std::path::PathBuf::from(".");
336 let req = WorktreeAgentRequest {
337 subtask: &subtask,
338 cwd: &cwd,
339 allowed_tools: None,
340 mcp_endpoint: None,
341 mcp_config_dir: None,
342 };
343 let err = agent.run_in(&req).await.unwrap_err();
344 assert!(err.to_string().contains("peer identity"), "{err}");
345 }
346
347 #[test]
348 fn only_a_full_peer_is_offered_the_subtask_twice() {
349 let declined = |reason| DispatchOutcome::Declined {
350 reason,
351 detail: String::new(),
352 };
353 assert!(retry_after_decline(&declined(DeclineReason::Busy)));
354 for reason in [
355 DeclineReason::RateLimited,
356 DeclineReason::NotAcceptingWork,
357 DeclineReason::RepoUnavailable,
358 DeclineReason::CommitUnavailable,
359 DeclineReason::AdapterUnavailable,
360 DeclineReason::PolicyDenied,
361 ] {
362 assert!(
363 !retry_after_decline(&declined(reason)),
364 "{reason:?} would decline identically on a retry"
365 );
366 }
367 assert!(!retry_after_decline(&DispatchOutcome::Completed {
368 patch: String::new(),
369 answer: String::new(),
370 adapter: "claude-code".into(),
371 duration_ms: 1,
372 }));
373 }
374
375 /// The boundary the pool's quarantine rests on: which declines are a fact
376 /// about the MACHINE (so it will answer every remaining subtask the same
377 /// way) rather than about the subtask that was offered.
378 ///
379 /// `Busy` is the one that must stay subtask-level among the reasons that
380 /// exist. Getting it wrong removes a healthy peer for being momentarily
381 /// full — the normal state of a working fleet.
382 #[test]
383 fn only_a_momentarily_full_peer_stays_in_the_pool() {
384 for reason in [
385 DeclineReason::NotAcceptingWork,
386 DeclineReason::RepoUnavailable,
387 DeclineReason::CommitUnavailable,
388 DeclineReason::AdapterUnavailable,
389 DeclineReason::RateLimited,
390 ] {
391 assert!(
392 decline_is_worker_level(reason),
393 "{reason:?} answers every subtask of this run identically"
394 );
395 }
396 assert!(!decline_is_worker_level(DeclineReason::Busy));
397 }
398
399 /// `PolicyDenied` has no producer in the workspace. It must NOT evict, and
400 /// this test exists to say that is a decision rather than an oversight: a
401 /// policy consult will most plausibly read the subtask's files or tools, so
402 /// treating it as a fact about the machine would drop a healthy peer the
403 /// first time one subtask touched a denied path.
404 #[test]
405 fn an_unwired_decline_reason_does_not_evict_the_peer() {
406 assert!(!decline_is_worker_level(DeclineReason::PolicyDenied));
407 }
408
409 /// What `reqwest` actually reports, because the whole quarantine rests on
410 /// it and the two cases are one variant apart.
411 ///
412 /// A peer runs the coding CLI INSIDE the dispatch request, so "nothing came
413 /// back yet" is the healthy case and must never be read as death. Measured
414 /// against real sockets rather than asserted from the docs.
415 #[tokio::test]
416 async fn reqwest_tells_a_dead_peer_from_a_slow_one() {
417 // Nothing listening: bind, read the port, drop the listener.
418 let dead_port = {
419 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
420 l.local_addr().unwrap().port()
421 };
422 let refused = reqwest::Client::new()
423 .post(format!("http://127.0.0.1:{dead_port}/"))
424 .send()
425 .await
426 .expect_err("nothing is listening");
427 assert!(refused.is_connect(), "a refused connection is a dead peer");
428
429 // Accepts the connection, then never answers — a peer mid-subtask.
430 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
431 let busy_port = listener.local_addr().unwrap().port();
432 let held = tokio::spawn(async move {
433 let _accepted = listener.accept().await;
434 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
435 });
436 let slow = reqwest::Client::builder()
437 .timeout(std::time::Duration::from_millis(300))
438 .build()
439 .unwrap()
440 .post(format!("http://127.0.0.1:{busy_port}/"))
441 .send()
442 .await
443 .expect_err("the deadline passes before an answer");
444 assert!(slow.is_timeout(), "it is a timeout");
445 assert!(
446 !slow.is_connect(),
447 "but NOT a connect failure — the peer is there and working"
448 );
449 held.abort();
450 }
451
452 /// The deadline the dispatch runs under has to cover the work the far side
453 /// is allowed to spend. `A2aClient`'s 30-second default — what this used to
454 /// inherit — is less than a fiftieth of it, so the orchestrator hung up on
455 /// every peer that was still editing.
456 #[test]
457 fn the_dispatch_deadline_covers_the_peer_s_whole_budget() {
458 // The unnegotiated case, which is every case today: the peer clamps to
459 // its own ceiling, so the orchestrator must wait at least that long.
460 let default = dispatch_deadline(None);
461 assert!(
462 default >= std::time::Duration::from_secs(super::super::DEFAULT_MAX_SUBTASK_SECS),
463 "hanging up before the peer's own ceiling aborts work that is still running"
464 );
465 assert!(
466 default > std::time::Duration::from_secs(30),
467 "the A2aClient default is what this exists to replace"
468 );
469 // And a negotiated budget is honoured, plus slack for the patch.
470 assert_eq!(
471 dispatch_deadline(Some(60)),
472 std::time::Duration::from_secs(60) + PATCH_TRANSFER_SLACK
473 );
474 // Reaching the machine is a separate, much shorter question.
475 assert!(CONNECT_TIMEOUT < default);
476 }
477
478 /// A peer that is not there vs. a peer that answered badly.
479 ///
480 /// Real sockets, because the distinction lives inside `reqwest::Error` —
481 /// `ClientError::Transport` wraps the body-decode failure too, so matching
482 /// the variant would quarantine a live peer that returned non-JSON. A
483 /// hand-built error could not tell these apart.
484 #[tokio::test]
485 async fn a_peer_that_is_gone_is_worker_level_and_one_that_answers_badly_is_not() {
486 use car_a2a::client::A2aClient;
487
488 // Nothing listening: bind, read the port, drop the listener.
489 let dead_port = {
490 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
491 l.local_addr().unwrap().port()
492 };
493 let err = A2aClient::new(format!("http://127.0.0.1:{dead_port}"))
494 .call::<_, serde_json::Value>("car/foremanSubtask", &serde_json::json!({}))
495 .await
496 .expect_err("nothing is listening");
497 assert!(
498 call_error_is_worker_level(&err),
499 "connect refused means the machine is not there: {err}"
500 );
501
502 // Listening, answers 200 with something that is not JSON.
503 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
504 let live_port = listener.local_addr().unwrap().port();
505 tokio::spawn(async move {
506 if let Ok((mut sock, _)) = listener.accept().await {
507 use tokio::io::AsyncWriteExt;
508 let _ = sock
509 .write_all(
510 b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
511 content-length: 8\r\n\r\nnot json",
512 )
513 .await;
514 let _ = sock.flush().await;
515 }
516 });
517 let err = A2aClient::new(format!("http://127.0.0.1:{live_port}"))
518 .call::<_, serde_json::Value>("car/foremanSubtask", &serde_json::json!({}))
519 .await
520 .expect_err("the body does not parse");
521 assert!(
522 !call_error_is_worker_level(&err),
523 "a peer that ANSWERED is alive, however unusably: {err}"
524 );
525 }
526}