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 };
342 let err = agent.run_in(&req).await.unwrap_err();
343 assert!(err.to_string().contains("peer identity"), "{err}");
344 }
345
346 #[test]
347 fn only_a_full_peer_is_offered_the_subtask_twice() {
348 let declined = |reason| DispatchOutcome::Declined {
349 reason,
350 detail: String::new(),
351 };
352 assert!(retry_after_decline(&declined(DeclineReason::Busy)));
353 for reason in [
354 DeclineReason::RateLimited,
355 DeclineReason::NotAcceptingWork,
356 DeclineReason::RepoUnavailable,
357 DeclineReason::CommitUnavailable,
358 DeclineReason::AdapterUnavailable,
359 DeclineReason::PolicyDenied,
360 ] {
361 assert!(
362 !retry_after_decline(&declined(reason)),
363 "{reason:?} would decline identically on a retry"
364 );
365 }
366 assert!(!retry_after_decline(&DispatchOutcome::Completed {
367 patch: String::new(),
368 answer: String::new(),
369 adapter: "claude-code".into(),
370 duration_ms: 1,
371 }));
372 }
373
374 /// The boundary the pool's quarantine rests on: which declines are a fact
375 /// about the MACHINE (so it will answer every remaining subtask the same
376 /// way) rather than about the subtask that was offered.
377 ///
378 /// `Busy` is the one that must stay subtask-level among the reasons that
379 /// exist. Getting it wrong removes a healthy peer for being momentarily
380 /// full — the normal state of a working fleet.
381 #[test]
382 fn only_a_momentarily_full_peer_stays_in_the_pool() {
383 for reason in [
384 DeclineReason::NotAcceptingWork,
385 DeclineReason::RepoUnavailable,
386 DeclineReason::CommitUnavailable,
387 DeclineReason::AdapterUnavailable,
388 DeclineReason::RateLimited,
389 ] {
390 assert!(
391 decline_is_worker_level(reason),
392 "{reason:?} answers every subtask of this run identically"
393 );
394 }
395 assert!(!decline_is_worker_level(DeclineReason::Busy));
396 }
397
398 /// `PolicyDenied` has no producer in the workspace. It must NOT evict, and
399 /// this test exists to say that is a decision rather than an oversight: a
400 /// policy consult will most plausibly read the subtask's files or tools, so
401 /// treating it as a fact about the machine would drop a healthy peer the
402 /// first time one subtask touched a denied path.
403 #[test]
404 fn an_unwired_decline_reason_does_not_evict_the_peer() {
405 assert!(!decline_is_worker_level(DeclineReason::PolicyDenied));
406 }
407
408 /// What `reqwest` actually reports, because the whole quarantine rests on
409 /// it and the two cases are one variant apart.
410 ///
411 /// A peer runs the coding CLI INSIDE the dispatch request, so "nothing came
412 /// back yet" is the healthy case and must never be read as death. Measured
413 /// against real sockets rather than asserted from the docs.
414 #[tokio::test]
415 async fn reqwest_tells_a_dead_peer_from_a_slow_one() {
416 // Nothing listening: bind, read the port, drop the listener.
417 let dead_port = {
418 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
419 l.local_addr().unwrap().port()
420 };
421 let refused = reqwest::Client::new()
422 .post(format!("http://127.0.0.1:{dead_port}/"))
423 .send()
424 .await
425 .expect_err("nothing is listening");
426 assert!(refused.is_connect(), "a refused connection is a dead peer");
427
428 // Accepts the connection, then never answers — a peer mid-subtask.
429 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
430 let busy_port = listener.local_addr().unwrap().port();
431 let held = tokio::spawn(async move {
432 let _accepted = listener.accept().await;
433 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
434 });
435 let slow = reqwest::Client::builder()
436 .timeout(std::time::Duration::from_millis(300))
437 .build()
438 .unwrap()
439 .post(format!("http://127.0.0.1:{busy_port}/"))
440 .send()
441 .await
442 .expect_err("the deadline passes before an answer");
443 assert!(slow.is_timeout(), "it is a timeout");
444 assert!(
445 !slow.is_connect(),
446 "but NOT a connect failure — the peer is there and working"
447 );
448 held.abort();
449 }
450
451 /// The deadline the dispatch runs under has to cover the work the far side
452 /// is allowed to spend. `A2aClient`'s 30-second default — what this used to
453 /// inherit — is less than a fiftieth of it, so the orchestrator hung up on
454 /// every peer that was still editing.
455 #[test]
456 fn the_dispatch_deadline_covers_the_peer_s_whole_budget() {
457 // The unnegotiated case, which is every case today: the peer clamps to
458 // its own ceiling, so the orchestrator must wait at least that long.
459 let default = dispatch_deadline(None);
460 assert!(
461 default >= std::time::Duration::from_secs(super::super::DEFAULT_MAX_SUBTASK_SECS),
462 "hanging up before the peer's own ceiling aborts work that is still running"
463 );
464 assert!(
465 default > std::time::Duration::from_secs(30),
466 "the A2aClient default is what this exists to replace"
467 );
468 // And a negotiated budget is honoured, plus slack for the patch.
469 assert_eq!(
470 dispatch_deadline(Some(60)),
471 std::time::Duration::from_secs(60) + PATCH_TRANSFER_SLACK
472 );
473 // Reaching the machine is a separate, much shorter question.
474 assert!(CONNECT_TIMEOUT < default);
475 }
476
477 /// A peer that is not there vs. a peer that answered badly.
478 ///
479 /// Real sockets, because the distinction lives inside `reqwest::Error` —
480 /// `ClientError::Transport` wraps the body-decode failure too, so matching
481 /// the variant would quarantine a live peer that returned non-JSON. A
482 /// hand-built error could not tell these apart.
483 #[tokio::test]
484 async fn a_peer_that_is_gone_is_worker_level_and_one_that_answers_badly_is_not() {
485 use car_a2a::client::A2aClient;
486
487 // Nothing listening: bind, read the port, drop the listener.
488 let dead_port = {
489 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
490 l.local_addr().unwrap().port()
491 };
492 let err = A2aClient::new(format!("http://127.0.0.1:{dead_port}"))
493 .call::<_, serde_json::Value>("car/foremanSubtask", &serde_json::json!({}))
494 .await
495 .expect_err("nothing is listening");
496 assert!(
497 call_error_is_worker_level(&err),
498 "connect refused means the machine is not there: {err}"
499 );
500
501 // Listening, answers 200 with something that is not JSON.
502 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
503 let live_port = listener.local_addr().unwrap().port();
504 tokio::spawn(async move {
505 if let Ok((mut sock, _)) = listener.accept().await {
506 use tokio::io::AsyncWriteExt;
507 let _ = sock
508 .write_all(
509 b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
510 content-length: 8\r\n\r\nnot json",
511 )
512 .await;
513 let _ = sock.flush().await;
514 }
515 });
516 let err = A2aClient::new(format!("http://127.0.0.1:{live_port}"))
517 .call::<_, serde_json::Value>("car/foremanSubtask", &serde_json::json!({}))
518 .await
519 .expect_err("the body does not parse");
520 assert!(
521 !call_error_is_worker_level(&err),
522 "a peer that ANSWERED is alive, however unusably: {err}"
523 );
524 }
525}