Skip to main content

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, &params).await?;
106        if retry_after_decline(&outcome) {
107            tokio::time::sleep(RETRY_DELAY).await;
108            outcome = self.dispatch_once(&client, &params).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}