Skip to main content

car_multi/patterns/foreman/
pool.rs

1//! Multiplayer farm-out: one Foreman run spread over several CAR instances.
2//!
3//! [`run_farm_out`](super::harness::run_farm_out) takes a single
4//! [`WorktreeAgent`] and runs every subtask through it. [`FleetPool`] *is* a
5//! `WorktreeAgent`, backed by several — the local coding CLI plus a worker on
6//! each reachable peer — so the harness, the gate, and the integration step are
7//! untouched. Distribution is a placement decision underneath an unchanged
8//! interface, which is the whole reason it costs no new soundness argument:
9//!
10//! - The worktree, the patch capture, the AST containment check, the build/test
11//!   leg, the policy consult, and the union integration all still happen on the
12//!   orchestrating host.
13//! - A remote worker's only output is edits in that worktree. A broken or
14//!   hostile one produces a patch the local gate then rejects, exactly like a
15//!   local agent having a bad day.
16//!
17//! ## Placement
18//!
19//! Least-loaded first, ties broken by declaration order, so a caller expresses
20//! preference by ordering its workers. Each worker has a `capacity` — the number
21//! of subtasks it will run at once — enforced by a semaphore, because a machine
22//! that accepts eight parallel coding CLIs when it can serve two turns a
23//! speed-up into a thrash.
24//!
25//! ## Failover, and why it must reset the worktree
26//!
27//! A worker that errors (network dropped, CLI missing, peer declined) hands the
28//! subtask to the next candidate. But a half-finished attempt leaves edits
29//! behind, and the next worker would then be editing someone else's partial
30//! work and the gate would attribute the mess to the subtask. So every failover
31//! **resets the worktree to the commit it was provisioned at** before retrying.
32//! A run that cannot reset does not retry: silently continuing from a dirty tree
33//! is the one outcome worse than failing the subtask.
34//!
35//! ## Quarantine
36//!
37//! Failing over per-subtask is not enough on its own. A worker that fails never
38//! takes a permit, so it keeps maximum availability and `candidates` ranks it
39//! FIRST again for the next subtask — a peer that dies mid-run is then tried,
40//! and times out, once for every subtask left (car#1323).
41//!
42//! So a REMOTE worker that returns [`ForemanError::Worker`] is excluded for the
43//! rest of the run. That error means the failure is a fact about the machine
44//! rather than the subtask, which is exactly the condition under which retrying
45//! it buys nothing; a subtask that merely failed returns
46//! [`ForemanError::Agent`] and changes nothing about the worker. The local
47//! worker is never quarantined — it is the one guaranteed-reachable machine,
48//! and the pool must not be able to empty itself.
49//!
50//! The bound is the worker's CAPACITY, not one attempt. A level runs under
51//! `futures::future::join_all`, so several subtasks can be inside a dying peer
52//! before any of them sets the flag — and `acquire_owned` WAITS on the
53//! top-ranked candidate rather than skipping to a free one, so the rest of the
54//! level parks on its semaphore. The flag is therefore re-read on the far side
55//! of the acquire, which is what turns "one dispatch per remaining subtask" into
56//! "one per permit". Wall-clock cost is one timeout, not N.
57//!
58//! Run-local and one-way: a peer that comes back stays out until the next run.
59//! Re-probing liveness mid-run is a different feature, and the cost this fixes
60//! is already paid by then. The one member of the set with a shorter natural
61//! life is a rate limit, whose window is hourly — a run longer than that loses a
62//! peer that would have been served again. Still not worth a re-probe.
63//!
64//! Nothing produces `ForemanError::Worker` locally today: `ForemanExternalAgent`
65//! never returns it. The remote-only guard is there because a local one would
66//! mean this host's own coding CLI vanished, which quarantining cannot route
67//! around and which could leave the pool with nothing to run.
68
69use std::sync::atomic::{AtomicBool, Ordering};
70use std::sync::{Arc, Mutex};
71
72use async_trait::async_trait;
73use serde::{Deserialize, Serialize};
74use tokio::sync::Semaphore;
75
76use super::harness::{AgentRunSummary, ForemanError, WorktreeAgent, WorktreeAgentRequest};
77
78/// One place subtasks can run.
79pub struct FleetWorker {
80    /// Instance name, as the fleet composite names it. Appears in the placement
81    /// ledger and the run report, so an operator can see which machine produced
82    /// which patch.
83    pub id: String,
84    /// How the subtask actually runs there — a local coding CLI, or a client
85    /// that dispatches to a peer and applies the patch it returns.
86    pub agent: Arc<dyn WorktreeAgent>,
87    /// Subtasks this worker runs at once. Clamped to at least 1: a worker with
88    /// no capacity is a worker that should not have been offered.
89    pub capacity: usize,
90    /// Whether this worker is on another host. Reported, not routed on — the
91    /// pool prefers whoever is free, and the caller expresses any other
92    /// preference through ordering.
93    pub remote: bool,
94}
95
96impl FleetWorker {
97    /// A worker on this host.
98    pub fn local(id: impl Into<String>, agent: Arc<dyn WorktreeAgent>, capacity: usize) -> Self {
99        Self {
100            id: id.into(),
101            agent,
102            capacity: capacity.max(1),
103            remote: false,
104        }
105    }
106
107    /// A worker on another CAR instance.
108    pub fn remote(id: impl Into<String>, agent: Arc<dyn WorktreeAgent>, capacity: usize) -> Self {
109        Self {
110            id: id.into(),
111            agent,
112            capacity: capacity.max(1),
113            remote: true,
114        }
115    }
116}
117
118/// One failed attempt at a subtask, kept so a run that eventually succeeded
119/// still shows which workers dropped it.
120///
121/// This type owns a JSON contract, not just an in-memory shape. `foreman.run`'s
122/// report has always rendered these field names, `car-cli`'s fleet output parses
123/// them, and a coder session now PERSISTS them in its snapshot — so a rename or
124/// a new required field here breaks a consumer or an on-disk record with no
125/// compile signal in this crate. `placements_wire_shape_is_pinned` is what makes
126/// that a test failure instead (car#1322).
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct FailedAttempt {
129    #[serde(rename = "worker")]
130    pub worker_id: String,
131    pub error: String,
132}
133
134/// Where a subtask ended up running.
135///
136/// Recorded when the worker RETURNS, which is before the per-patch gate rules on
137/// what it produced — so a placement says a machine ran a subtask, not that its
138/// patch was accepted or delivered. A caller making a claim about a delivered
139/// artifact has to intersect this with what it actually integrated.
140///
141/// Carries the same JSON contract as [`FailedAttempt`]; see its note.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Placement {
144    pub subtask_id: String,
145    /// The worker that produced the edits, or `None` when every worker failed.
146    #[serde(rename = "worker")]
147    pub worker_id: Option<String>,
148    /// Whether that worker was a peer rather than this host. **Meaningless when
149    /// `worker_id` is `None`** — nothing ran, so there is no location, and the
150    /// all-failed record stores `false` for want of an answer rather than as a
151    /// claim. Readers must not render a location without a worker.
152    pub remote: bool,
153    /// Workers that failed first, in the order they were tried.
154    #[serde(rename = "failed_attempts", default)]
155    pub attempts: Vec<FailedAttempt>,
156}
157
158/// A [`WorktreeAgent`] that spreads subtasks over a set of workers.
159pub struct FleetPool {
160    slots: Vec<Slot>,
161    ledger: Mutex<Vec<Placement>>,
162}
163
164struct Slot {
165    worker: FleetWorker,
166    permits: Arc<Semaphore>,
167    /// Set when this worker failed with [`ForemanError::Worker`] — a fact about
168    /// the machine, not the subtask. It is then excluded for the rest of the
169    /// run. Never set for a local worker: this host is the one guaranteed-
170    /// reachable machine, and removing it could empty the pool.
171    quarantined: AtomicBool,
172}
173
174impl FleetPool {
175    /// Build a pool. Workers are tried in the order given when equally loaded,
176    /// so put the preferred one first.
177    ///
178    /// A pool with no workers is accepted and fails every subtask with a clear
179    /// message — a caller that filtered its fleet down to nothing gets told so
180    /// rather than getting a silent no-op run.
181    pub fn new(workers: Vec<FleetWorker>) -> Self {
182        let slots = workers
183            .into_iter()
184            .map(|worker| {
185                let permits = Arc::new(Semaphore::new(worker.capacity.max(1)));
186                Slot {
187                    worker,
188                    permits,
189                    quarantined: AtomicBool::new(false),
190                }
191            })
192            .collect();
193        Self {
194            slots,
195            ledger: Mutex::new(Vec::new()),
196        }
197    }
198
199    /// Total subtasks this pool can run at once.
200    pub fn capacity(&self) -> usize {
201        self.slots.iter().map(|s| s.worker.capacity).sum()
202    }
203
204    pub fn worker_ids(&self) -> Vec<&str> {
205        self.slots.iter().map(|s| s.worker.id.as_str()).collect()
206    }
207
208    /// Where each subtask ran, in completion order.
209    pub fn placements(&self) -> Vec<Placement> {
210        self.ledger
211            .lock()
212            .unwrap_or_else(|e| e.into_inner())
213            .clone()
214    }
215
216    fn record(&self, placement: Placement) {
217        self.ledger
218            .lock()
219            .unwrap_or_else(|e| e.into_inner())
220            .push(placement);
221    }
222
223    /// Workers removed from the pool mid-run, in declaration order.
224    ///
225    /// Reported next to the pool's build-time exclusions so a degraded run says
226    /// so. A worker lands here only via [`ForemanError::Worker`] — see
227    /// [`Self::run_in`].
228    pub fn quarantined(&self) -> Vec<&str> {
229        self.slots
230            .iter()
231            .filter(|s| s.quarantined.load(Ordering::Relaxed))
232            .map(|s| s.worker.id.as_str())
233            .collect()
234    }
235
236    /// Slot indices, least-loaded first, ties in declaration order.
237    ///
238    /// Quarantined workers are FILTERED OUT rather than sorted last. Demotion
239    /// would leave a dead peer in the list, so every subtask whose healthy
240    /// worker errors fails over into it and pays a full connect timeout — the
241    /// complaint car#1323 is about, in miniature. (It would NOT be reached
242    /// merely because a healthy worker is busy: `acquire_owned` waits on the
243    /// top-ranked candidate rather than skipping to a free one.)
244    ///
245    /// A snapshot: another subtask may take a permit between ranking and
246    /// acquiring. That is fine — the ordering is a preference, and the semaphore
247    /// is what actually bounds a worker.
248    fn candidates(&self) -> Vec<usize> {
249        let mut order: Vec<usize> = (0..self.slots.len())
250            .filter(|&i| !self.slots[i].quarantined.load(Ordering::Relaxed))
251            .collect();
252        order.sort_by_key(|&i| {
253            (
254                std::cmp::Reverse(self.slots[i].permits.available_permits()),
255                i,
256            )
257        });
258        order
259    }
260}
261
262#[async_trait]
263impl WorktreeAgent for FleetPool {
264    async fn run_in(
265        &self,
266        req: &WorktreeAgentRequest<'_>,
267    ) -> Result<AgentRunSummary, ForemanError> {
268        if self.slots.is_empty() {
269            return Err(ForemanError::Agent(
270                "no workers in the fleet pool: nothing can run this subtask".into(),
271            ));
272        }
273
274        let subtask_id = req.subtask.id.clone();
275        let mut attempts: Vec<FailedAttempt> = Vec::new();
276
277        // Whether any worker has actually been handed this subtask yet. NOT the
278        // old `nth > 0`: a candidate can be skipped without running (quarantined
279        // since the list was ranked, or a closed semaphore), and resetting for
280        // one of those would run a git operation against a tree nothing touched
281        // — whose failure aborts the subtask.
282        let mut attempted = false;
283
284        for idx in self.candidates() {
285            let slot = &self.slots[idx];
286
287            // Cheap skip for a peer already known dead when this subtask got
288            // here — saves queueing on its semaphore at all. NOT sufficient on
289            // its own; see the re-check below.
290            if slot.quarantined.load(Ordering::Relaxed) {
291                continue;
292            }
293
294            // Every failover after an attempt starts from the base the worktree
295            // was provisioned at. Skipping this would hand the next worker a
296            // tree carrying a failed attempt's half-written edits.
297            if attempted {
298                if let Err(e) = reset_worktree(req.cwd) {
299                    // Record before returning. This subtask has already been
300                    // through at least one worker — `attempted` — and bailing
301                    // without a ledger row dropped every one of those attempts,
302                    // so `placements()` did not even name the subtask. The run
303                    // whose receipt someone wants is exactly this one.
304                    //
305                    // `worker_id: None` and no synthetic attempt against
306                    // `slot.worker.id`: that worker never received the subtask,
307                    // and listing it under `failed_attempts` would read as a
308                    // worker that ran and failed. The reset failure reaches the
309                    // caller in the error; the ledger reports only what ran.
310                    self.record(Placement {
311                        subtask_id,
312                        worker_id: None,
313                        remote: false,
314                        attempts,
315                    });
316                    return Err(ForemanError::Git(format!(
317                        "cannot fail over to `{}`: {e}",
318                        slot.worker.id
319                    )));
320                }
321            }
322
323            let _permit = match slot.permits.clone().acquire_owned().await {
324                Ok(p) => p,
325                Err(_) => {
326                    // The semaphore is owned by this pool and never closed, so
327                    // this is unreachable in practice; treat it as the worker
328                    // being unavailable rather than panicking a whole run.
329                    attempts.push(FailedAttempt {
330                        worker_id: slot.worker.id.clone(),
331                        error: "worker capacity closed".into(),
332                    });
333                    continue;
334                }
335            };
336
337            // THE load-bearing check, and it has to be on this side of the
338            // acquire. `acquire_owned` waits — it does not skip a busy worker —
339            // so a level of N subtasks parks N-minus-capacity tasks on this
340            // semaphore, all of which passed the check above before anything had
341            // failed. Each permit a timing-out holder releases would otherwise
342            // hand the subtask straight back to the machine that was declared
343            // dead while it waited. Checking before the acquire alone buys
344            // nothing within a level, which is the regime that matters.
345            if slot.quarantined.load(Ordering::Relaxed) {
346                drop(_permit);
347                continue;
348            }
349
350            attempted = true;
351            match slot.worker.agent.run_in(req).await {
352                Ok(summary) => {
353                    self.record(Placement {
354                        subtask_id,
355                        worker_id: Some(slot.worker.id.clone()),
356                        remote: slot.worker.remote,
357                        attempts,
358                    });
359                    return Ok(summary);
360                }
361                Err(e) => {
362                    // A worker-level failure is a fact about the machine, so it
363                    // holds for every remaining subtask. Without this the worker
364                    // keeps MAXIMUM availability — it never took a permit — and
365                    // `candidates` therefore ranks it FIRST for each one in turn
366                    // (car#1323).
367                    //
368                    // Remote only. A local `Worker` error means this host's own
369                    // coding CLI went missing, which quarantining cannot route
370                    // around and which would leave the pool with nothing.
371                    // `swap` rather than `store`: a concurrent level can land
372                    // several worker-level failures on the same slot, and the
373                    // log line is worth exactly once.
374                    if slot.worker.remote
375                        && matches!(e, ForemanError::Worker(_))
376                        && !slot.quarantined.swap(true, Ordering::Relaxed)
377                    {
378                        tracing::warn!(
379                            worker = %slot.worker.id,
380                            error = %e,
381                            "quarantining fleet worker for the rest of the run"
382                        );
383                    }
384                    tracing::warn!(
385                        subtask = %subtask_id,
386                        worker = %slot.worker.id,
387                        error = %e,
388                        "fleet worker failed; trying the next"
389                    );
390                    attempts.push(FailedAttempt {
391                        worker_id: slot.worker.id.clone(),
392                        error: e.to_string(),
393                    });
394                }
395            }
396        }
397
398        if !attempted {
399            // Every candidate was skipped without being offered the subtask —
400            // in practice, all of them quarantined. Reachable only for a pool
401            // with no local worker, since the local one never is. Falling
402            // through to the tail below would report "every fleet worker
403            // failed" with an empty list of attempts, for a subtask nothing was
404            // asked to run. Both the pre-acquire and post-acquire skips land
405            // here, so there is one guard rather than one per path.
406            return Err(ForemanError::Agent(format!(
407                "no worker could be offered this subtask; quarantined for this run: {}",
408                self.quarantined().join(", ")
409            )));
410        }
411
412        let detail = attempts
413            .iter()
414            .map(|a| format!("{}: {}", a.worker_id, a.error))
415            .collect::<Vec<_>>()
416            .join("; ");
417        self.record(Placement {
418            subtask_id,
419            worker_id: None,
420            remote: false,
421            attempts,
422        });
423        Err(ForemanError::Agent(format!(
424            "every fleet worker failed — {detail}"
425        )))
426    }
427}
428
429/// Return a worktree to the commit it was checked out at.
430///
431/// `reset --hard` drops tracked edits; `clean -fd` drops the untracked files a
432/// half-finished agent left behind. Both are needed: a subtask that created new
433/// files and then errored would otherwise leave them for the next worker, and
434/// the gate would read them as that worker's output.
435fn reset_worktree(cwd: &std::path::Path) -> Result<(), String> {
436    for args in [vec!["reset", "--hard", "--quiet"], vec!["clean", "-fdq"]] {
437        let out = std::process::Command::new("git")
438            .arg("-C")
439            .arg(cwd)
440            .args(&args)
441            .output()
442            .map_err(|e| format!("git {}: {e}", args.join(" ")))?;
443        if !out.status.success() {
444            return Err(format!(
445                "git {} failed: {}",
446                args.join(" "),
447                String::from_utf8_lossy(&out.stderr).trim()
448            ));
449        }
450    }
451    Ok(())
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::patterns::foreman::harness::Subtask;
458    use std::path::{Path, PathBuf};
459
460    /// These field names are a CONTRACT, not an implementation detail.
461    ///
462    /// `foreman.run`'s report renders them, `car fleet`'s output parses them,
463    /// and a coder session persists them in an on-disk snapshot — none of which
464    /// this crate can see. Before car#1322 they were literals in one
465    /// hand-written renderer, which at least documented the shape at the point
466    /// of use; they are serde attributes now, so dropping a rename would break
467    /// three consumers and read a stale snapshot wrong, all green. This is the
468    /// only thing standing there.
469    ///
470    /// Adding a REQUIRED field here has the same reach: every existing session
471    /// snapshot then fails to load.
472    #[test]
473    fn placement_wire_shape_is_pinned() {
474        let json = serde_json::to_value(vec![Placement {
475            subtask_id: "s1".into(),
476            worker_id: Some("studio".into()),
477            remote: true,
478            attempts: vec![FailedAttempt {
479                worker_id: "laptop".into(),
480                error: "boom".into(),
481            }],
482        }])
483        .unwrap();
484
485        assert_eq!(
486            json,
487            serde_json::json!([{
488                "subtask_id": "s1",
489                "worker": "studio",
490                "remote": true,
491                "failed_attempts": [{ "worker": "laptop", "error": "boom" }],
492            }])
493        );
494
495        // And back, so a persisted snapshot written by this version still loads.
496        let round: Vec<Placement> = serde_json::from_value(json).unwrap();
497        assert_eq!(round[0].worker_id.as_deref(), Some("studio"));
498        assert_eq!(round[0].attempts[0].worker_id, "laptop");
499    }
500
501    /// Which keys a snapshot may omit, pinned because the answer is not obvious
502    /// and the wrong belief about it produces a compatibility break.
503    ///
504    /// serde_derive DOES implicitly default an `Option` field to `None` when the
505    /// key is absent — no `#[serde(default)]` needed — while a `Vec` does not,
506    /// which is why `failed_attempts` carries one and `worker` does not. That
507    /// asymmetry looks like an oversight and is not. `subtask_id` and `remote`
508    /// are genuinely required, and a record missing either is malformed rather
509    /// than partial.
510    #[test]
511    fn optional_keys_are_optional_and_required_ones_are_required() {
512        let p: Placement = serde_json::from_value(serde_json::json!({
513            "subtask_id": "s1",
514            "remote": false,
515        }))
516        .expect("worker and failed_attempts may both be absent");
517        assert_eq!(p.worker_id, None);
518        assert!(p.attempts.is_empty());
519
520        for missing in [
521            serde_json::json!({ "remote": false }),
522            serde_json::json!({ "subtask_id": "s1" }),
523        ] {
524            assert!(
525                serde_json::from_value::<Placement>(missing.clone()).is_err(),
526                "{missing} must not deserialize"
527            );
528        }
529    }
530    use std::process::Command;
531    use std::sync::atomic::{AtomicUsize, Ordering};
532
533    fn git(cwd: &Path, args: &[&str]) {
534        let out = Command::new("git")
535            .args(args)
536            .current_dir(cwd)
537            .output()
538            .expect("git runs");
539        assert!(
540            out.status.success(),
541            "git {args:?}: {}",
542            String::from_utf8_lossy(&out.stderr)
543        );
544    }
545
546    /// A git worktree standing in for one the harness provisions.
547    fn worktree() -> tempfile::TempDir {
548        let dir = tempfile::tempdir().unwrap();
549        let root = dir.path();
550        git(root, &["init", "-q", "-b", "main"]);
551        git(root, &["config", "user.email", "t@t.t"]);
552        git(root, &["config", "user.name", "t"]);
553        git(root, &["config", "core.autocrlf", "false"]);
554        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
555        git(root, &["add", "-A"]);
556        git(root, &["commit", "-qm", "base"]);
557        dir
558    }
559
560    /// Records which worker ran, and how many were in flight at the peak.
561    struct Counting {
562        id: &'static str,
563        inflight: Arc<AtomicUsize>,
564        peak: Arc<AtomicUsize>,
565        ran: Arc<Mutex<Vec<String>>>,
566    }
567
568    #[async_trait]
569    impl WorktreeAgent for Counting {
570        async fn run_in(
571            &self,
572            req: &WorktreeAgentRequest<'_>,
573        ) -> Result<AgentRunSummary, ForemanError> {
574            let now = self.inflight.fetch_add(1, Ordering::SeqCst) + 1;
575            self.peak.fetch_max(now, Ordering::SeqCst);
576            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
577            self.ran
578                .lock()
579                .unwrap()
580                .push(format!("{}:{}", self.id, req.subtask.id));
581            self.inflight.fetch_sub(1, Ordering::SeqCst);
582            Ok(AgentRunSummary {
583                answer: self.id.to_string(),
584            })
585        }
586    }
587
588    /// Writes a file, then fails — the partial-work case failover must clean up.
589    struct MessyFailure;
590    #[async_trait]
591    impl WorktreeAgent for MessyFailure {
592        async fn run_in(
593            &self,
594            req: &WorktreeAgentRequest<'_>,
595        ) -> Result<AgentRunSummary, ForemanError> {
596            std::fs::write(req.cwd.join("half-done.txt"), "partial\n").unwrap();
597            std::fs::write(req.cwd.join("seed.txt"), "clobbered\n").unwrap();
598            Err(ForemanError::Agent("peer dropped mid-subtask".into()))
599        }
600    }
601
602    /// Asserts the worktree it receives is clean at base.
603    struct ExpectsCleanTree;
604    #[async_trait]
605    impl WorktreeAgent for ExpectsCleanTree {
606        async fn run_in(
607            &self,
608            req: &WorktreeAgentRequest<'_>,
609        ) -> Result<AgentRunSummary, ForemanError> {
610            if req.cwd.join("half-done.txt").exists() {
611                return Err(ForemanError::Agent(
612                    "inherited a failed worker's untracked file".into(),
613                ));
614            }
615            let seed = std::fs::read_to_string(req.cwd.join("seed.txt")).unwrap();
616            if seed != "seed\n" {
617                return Err(ForemanError::Agent(
618                    "inherited a failed worker's tracked edit".into(),
619                ));
620            }
621            Ok(AgentRunSummary {
622                answer: "clean".into(),
623            })
624        }
625    }
626
627    /// A failover that cannot reset the worktree still owes a ledger row.
628    ///
629    /// The subtask has been through a worker by then, and returning without
630    /// recording dropped that attempt AND the subtask itself — `placements()`
631    /// did not name it at all. Someone reading the ledger to find out what
632    /// happened to a failed run got silence about the one that failed.
633    #[tokio::test]
634    async fn a_failover_that_cannot_reset_the_tree_still_records_what_ran() {
635        let pool = FleetPool::new(vec![
636            FleetWorker::local("first", Arc::new(MessyFailure), 1),
637            FleetWorker::local("second", Arc::new(ExpectsCleanTree), 1),
638        ]);
639        // NOT a git repo, so `reset_worktree` fails on the failover — the same
640        // shape as a repo whose `.git` went away mid-run.
641        let dir = tempfile::tempdir().unwrap();
642        let cwd = dir.path().to_path_buf();
643        let a = Subtask::files_only("a", "a", vec![]);
644        let err = pool.run_in(&request(&a, &cwd)).await.unwrap_err();
645        assert!(
646            matches!(err, ForemanError::Git(_)),
647            "expected the reset failure to surface: {err:?}"
648        );
649
650        let placements = pool.placements();
651        assert_eq!(placements.len(), 1, "the subtask must appear in the ledger");
652        assert_eq!(placements[0].subtask_id, "a");
653        // Nobody completed it.
654        assert_eq!(placements[0].worker_id, None);
655        // And the worker that DID run and fail is named, once.
656        assert_eq!(placements[0].attempts.len(), 1);
657        assert_eq!(placements[0].attempts[0].worker_id, "first");
658        // `second` never received the subtask, so it must not appear as an
659        // attempt — that is the false attribution the ledger split avoids.
660        assert!(
661            !placements[0]
662                .attempts
663                .iter()
664                .any(|a| a.worker_id == "second"),
665            "a worker the failover never reached is not a failed attempt"
666        );
667    }
668
669    /// Counts every dispatch, and fails each with a caller-chosen error.
670    struct Failing {
671        calls: Arc<AtomicUsize>,
672        worker_level: bool,
673    }
674    #[async_trait]
675    impl WorktreeAgent for Failing {
676        async fn run_in(
677            &self,
678            _req: &WorktreeAgentRequest<'_>,
679        ) -> Result<AgentRunSummary, ForemanError> {
680            self.calls.fetch_add(1, Ordering::SeqCst);
681            Err(if self.worker_level {
682                ForemanError::Worker("peer is not there".into())
683            } else {
684                ForemanError::Agent("the subtask failed".into())
685            })
686        }
687    }
688
689    fn always_ok(id: &'static str) -> Arc<Counting> {
690        Arc::new(Counting {
691            id,
692            inflight: Arc::new(AtomicUsize::new(0)),
693            peak: Arc::new(AtomicUsize::new(0)),
694            ran: Arc::new(Mutex::new(Vec::new())),
695        })
696    }
697
698    /// The bug: a worker that fails never takes a permit, so it keeps maximum
699    /// availability and `candidates` ranks it first for EVERY remaining subtask
700    /// — a peer that died mid-run is re-dispatched to, and times out, once per
701    /// subtask left (car#1323).
702    #[tokio::test]
703    async fn a_worker_level_failure_takes_the_peer_out_for_the_rest_of_the_run() {
704        let calls = Arc::new(AtomicUsize::new(0));
705        let pool = FleetPool::new(vec![
706            FleetWorker::remote(
707                "dead-peer",
708                Arc::new(Failing {
709                    calls: Arc::clone(&calls),
710                    worker_level: true,
711                }),
712                1,
713            ),
714            FleetWorker::local("here", always_ok("here"), 1),
715        ]);
716        let dir = worktree();
717        let cwd = dir.path().to_path_buf();
718
719        for id in ["a", "b", "c"] {
720            let sub = Subtask::files_only(id, id, vec![]);
721            pool.run_in(&request(&sub, &cwd))
722                .await
723                .expect("the local worker picks it up");
724        }
725
726        assert_eq!(
727            calls.load(Ordering::SeqCst),
728            1,
729            "the dead peer must be offered exactly one subtask, not one per subtask"
730        );
731        assert_eq!(pool.quarantined(), vec!["dead-peer"]);
732        // Only the first subtask records the failed attempt; the rest never
733        // reach that worker at all.
734        let with_attempts = pool
735            .placements()
736            .iter()
737            .filter(|p| !p.attempts.is_empty())
738            .count();
739        assert_eq!(with_attempts, 1);
740    }
741
742    /// Fails worker-level, but only after giving every other subtask in the
743    /// level time to queue behind it.
744    struct SlowFailing {
745        calls: Arc<AtomicUsize>,
746    }
747    #[async_trait]
748    impl WorktreeAgent for SlowFailing {
749        async fn run_in(
750            &self,
751            _req: &WorktreeAgentRequest<'_>,
752        ) -> Result<AgentRunSummary, ForemanError> {
753            self.calls.fetch_add(1, Ordering::SeqCst);
754            tokio::time::sleep(std::time::Duration::from_millis(60)).await;
755            Err(ForemanError::Worker("peer is not there".into()))
756        }
757    }
758
759    /// A Foreman level runs under `futures::future::join_all`, and that is the
760    /// regime the sequential tests above never enter.
761    ///
762    /// `acquire_owned` WAITS on the top-ranked candidate rather than skipping to
763    /// a free one, so every subtask in the level parks on the dead peer's
764    /// semaphore having already passed the pre-acquire check — back when nothing
765    /// had failed yet. Each permit a timing-out holder releases then hands the
766    /// subtask straight back to the machine that was declared dead while it
767    /// waited, unless the flag is re-read on the far side of the acquire.
768    ///
769    /// So the bound is the peer's CAPACITY, not the size of the level.
770    #[tokio::test]
771    async fn a_whole_level_queued_behind_a_dying_peer_stops_dispatching_to_it() {
772        const CAPACITY: usize = 2;
773        const LEVEL: usize = 8;
774
775        // The peer is declared FIRST and out-capacities the local worker, so it
776        // is top-ranked at the start of the level and stays top-ranked once both
777        // are saturated (`candidates` breaks a tie by declaration order). That
778        // is what parks the rest of the level on its semaphore rather than
779        // spreading them — the shape a real run has when a peer is the fast
780        // machine and this host is the fallback.
781        let calls = Arc::new(AtomicUsize::new(0));
782        let pool = FleetPool::new(vec![
783            FleetWorker::remote(
784                "dying-peer",
785                Arc::new(SlowFailing {
786                    calls: Arc::clone(&calls),
787                }),
788                CAPACITY,
789            ),
790            FleetWorker::local("here", always_ok("here"), 1),
791        ]);
792
793        // A worktree per subtask, as the harness provisions them — so the
794        // failover resets do not contend on one repo.
795        let dirs: Vec<tempfile::TempDir> = (0..LEVEL).map(|_| worktree()).collect();
796        let cwds: Vec<PathBuf> = dirs.iter().map(|d| d.path().to_path_buf()).collect();
797        let subtasks: Vec<Subtask> = (0..LEVEL)
798            .map(|i| Subtask::files_only(format!("s{i}"), "go", vec![]))
799            .collect();
800
801        let reqs: Vec<WorktreeAgentRequest<'_>> = subtasks
802            .iter()
803            .zip(cwds.iter())
804            .map(|(sub, cwd)| request(sub, cwd))
805            .collect();
806        let results = futures::future::join_all(reqs.iter().map(|r| pool.run_in(r))).await;
807
808        for r in &results {
809            r.as_ref()
810                .expect("the local worker picks every one of them up");
811        }
812        assert_eq!(pool.quarantined(), vec!["dying-peer"]);
813        assert!(
814            calls.load(Ordering::SeqCst) <= CAPACITY,
815            "a level of {LEVEL} must not dispatch past the peer's capacity of \
816             {CAPACITY} once it is quarantined; got {}",
817            calls.load(Ordering::SeqCst)
818        );
819    }
820
821    /// The boundary the whole design rests on. A subtask that merely failed says
822    /// nothing about the machine, so quarantining on it would remove a healthy
823    /// worker for having had one bad task — worse than the bug being fixed.
824    #[tokio::test]
825    async fn a_subtask_that_failed_does_not_quarantine_the_worker_that_ran_it() {
826        let calls = Arc::new(AtomicUsize::new(0));
827        let pool = FleetPool::new(vec![
828            FleetWorker::remote(
829                "healthy-peer",
830                Arc::new(Failing {
831                    calls: Arc::clone(&calls),
832                    worker_level: false,
833                }),
834                1,
835            ),
836            FleetWorker::local("here", always_ok("here"), 1),
837        ]);
838        let dir = worktree();
839        let cwd = dir.path().to_path_buf();
840
841        for id in ["a", "b", "c"] {
842            let sub = Subtask::files_only(id, id, vec![]);
843            pool.run_in(&request(&sub, &cwd)).await.expect("local runs");
844        }
845
846        assert_eq!(
847            calls.load(Ordering::SeqCst),
848            3,
849            "an `Agent` failure must leave the worker in the pool"
850        );
851        assert!(pool.quarantined().is_empty());
852    }
853
854    /// The local worker is the one guaranteed-reachable machine. A `Worker`
855    /// error from it means this host's own CLI went missing, which quarantining
856    /// cannot route around — and doing it anyway can empty the pool.
857    #[tokio::test]
858    async fn the_local_worker_is_never_quarantined() {
859        let calls = Arc::new(AtomicUsize::new(0));
860        let pool = FleetPool::new(vec![FleetWorker::local(
861            "here",
862            Arc::new(Failing {
863                calls: Arc::clone(&calls),
864                worker_level: true,
865            }),
866            1,
867        )]);
868        let dir = worktree();
869        let cwd = dir.path().to_path_buf();
870
871        for id in ["a", "b"] {
872            let sub = Subtask::files_only(id, id, vec![]);
873            pool.run_in(&request(&sub, &cwd))
874                .await
875                .expect_err("it fails, but it stays in the pool");
876        }
877
878        assert_eq!(calls.load(Ordering::SeqCst), 2);
879        assert!(pool.quarantined().is_empty());
880    }
881
882    /// An all-remote pool can quarantine itself down to nothing. Without an
883    /// explicit check the loop body never runs and the tail reports
884    /// "every fleet worker failed — " with no attempts behind it, for a subtask
885    /// that was never offered to anyone.
886    #[tokio::test]
887    async fn an_all_remote_pool_that_quarantines_everyone_says_so() {
888        let calls = Arc::new(AtomicUsize::new(0));
889        let pool = FleetPool::new(vec![FleetWorker::remote(
890            "only-peer",
891            Arc::new(Failing {
892                calls: Arc::clone(&calls),
893                worker_level: true,
894            }),
895            1,
896        )]);
897        let dir = worktree();
898        let cwd = dir.path().to_path_buf();
899
900        let a = Subtask::files_only("a", "a", vec![]);
901        pool.run_in(&request(&a, &cwd)).await.unwrap_err();
902        let b = Subtask::files_only("b", "b", vec![]);
903        let err = pool.run_in(&request(&b, &cwd)).await.unwrap_err();
904
905        assert_eq!(calls.load(Ordering::SeqCst), 1);
906        let text = err.to_string();
907        assert!(
908            text.contains("quarantined") && text.contains("only-peer"),
909            "the error must name the quarantine and the peer: {text}"
910        );
911    }
912
913    fn request<'a>(subtask: &'a Subtask, cwd: &'a PathBuf) -> WorktreeAgentRequest<'a> {
914        WorktreeAgentRequest {
915            subtask,
916            cwd,
917            allowed_tools: None,
918            mcp_endpoint: None,
919            mcp_config_dir: None,
920        }
921    }
922
923    fn counting(
924        id: &'static str,
925        ran: &Arc<Mutex<Vec<String>>>,
926    ) -> (Arc<Counting>, Arc<AtomicUsize>) {
927        let peak = Arc::new(AtomicUsize::new(0));
928        (
929            Arc::new(Counting {
930                id,
931                inflight: Arc::new(AtomicUsize::new(0)),
932                peak: Arc::clone(&peak),
933                ran: Arc::clone(ran),
934            }),
935            peak,
936        )
937    }
938
939    #[tokio::test]
940    async fn work_spreads_across_workers_instead_of_queueing_on_one() {
941        let ran = Arc::new(Mutex::new(Vec::new()));
942        let (here, _) = counting("here", &ran);
943        let (studio, _) = counting("studio", &ran);
944        let pool = FleetPool::new(vec![
945            FleetWorker::local("here", here, 1),
946            FleetWorker::remote("studio", studio, 1),
947        ]);
948        assert_eq!(pool.capacity(), 2);
949
950        let dir = worktree();
951        let cwd = dir.path().to_path_buf();
952        let a = Subtask::files_only("a", "a", vec![]);
953        let b = Subtask::files_only("b", "b", vec![]);
954        let (req_a, req_b) = (request(&a, &cwd), request(&b, &cwd));
955        let (ra, rb) = tokio::join!(pool.run_in(&req_a), pool.run_in(&req_b));
956        ra.unwrap();
957        rb.unwrap();
958
959        let placements = pool.placements();
960        assert_eq!(placements.len(), 2);
961        let used: std::collections::HashSet<String> = placements
962            .iter()
963            .filter_map(|p| p.worker_id.clone())
964            .collect();
965        assert_eq!(used.len(), 2, "both workers took a subtask: {placements:?}");
966    }
967
968    #[tokio::test]
969    async fn a_worker_never_exceeds_its_capacity() {
970        let ran = Arc::new(Mutex::new(Vec::new()));
971        let (only, peak) = counting("only", &ran);
972        let pool = FleetPool::new(vec![FleetWorker::local("only", only, 1)]);
973        let dir = worktree();
974        let cwd = dir.path().to_path_buf();
975        let a = Subtask::files_only("a", "a", vec![]);
976        let b = Subtask::files_only("b", "b", vec![]);
977        let c = Subtask::files_only("c", "c", vec![]);
978        let (req_a, req_b, req_c) = (request(&a, &cwd), request(&b, &cwd), request(&c, &cwd));
979        let _ = tokio::join!(
980            pool.run_in(&req_a),
981            pool.run_in(&req_b),
982            pool.run_in(&req_c)
983        );
984        assert_eq!(
985            peak.load(Ordering::SeqCst),
986            1,
987            "capacity 1 means one at a time, however many subtasks arrive"
988        );
989        assert_eq!(pool.placements().len(), 3);
990    }
991
992    #[tokio::test]
993    async fn failover_hands_the_next_worker_a_clean_tree() {
994        let pool = FleetPool::new(vec![
995            FleetWorker::remote("flaky", Arc::new(MessyFailure), 1),
996            FleetWorker::local("here", Arc::new(ExpectsCleanTree), 1),
997        ]);
998        let dir = worktree();
999        let cwd = dir.path().to_path_buf();
1000        let s = Subtask::files_only("s", "s", vec![]);
1001        let summary = pool
1002            .run_in(&request(&s, &cwd))
1003            .await
1004            .expect("second worker takes it");
1005        assert_eq!(summary.answer, "clean");
1006
1007        let placements = pool.placements();
1008        assert_eq!(placements[0].worker_id.as_deref(), Some("here"));
1009        assert_eq!(placements[0].attempts.len(), 1);
1010        assert_eq!(placements[0].attempts[0].worker_id, "flaky");
1011        assert!(!cwd.join("half-done.txt").exists(), "reset cleaned up");
1012    }
1013
1014    #[tokio::test]
1015    async fn every_worker_failing_reports_all_of_them() {
1016        let pool = FleetPool::new(vec![
1017            FleetWorker::remote("a", Arc::new(MessyFailure), 1),
1018            FleetWorker::remote("b", Arc::new(MessyFailure), 1),
1019        ]);
1020        let dir = worktree();
1021        let cwd = dir.path().to_path_buf();
1022        let s = Subtask::files_only("s", "s", vec![]);
1023        let err = pool.run_in(&request(&s, &cwd)).await.unwrap_err();
1024        let msg = err.to_string();
1025        assert!(msg.contains('a') && msg.contains('b'), "{msg}");
1026        let placements = pool.placements();
1027        assert!(placements[0].worker_id.is_none());
1028        assert_eq!(placements[0].attempts.len(), 2);
1029    }
1030
1031    #[tokio::test]
1032    async fn an_empty_pool_says_so_instead_of_silently_doing_nothing() {
1033        let pool = FleetPool::new(Vec::new());
1034        let dir = worktree();
1035        let cwd = dir.path().to_path_buf();
1036        let s = Subtask::files_only("s", "s", vec![]);
1037        let err = pool.run_in(&request(&s, &cwd)).await.unwrap_err();
1038        assert!(err.to_string().contains("no workers"), "{err}");
1039    }
1040
1041    /// Stands in for a peer: it edits its OWN worktree (never the caller's),
1042    /// captures a patch, and the "orchestrator" applies that patch locally —
1043    /// the exact round trip `car_fleet::worker` specifies.
1044    struct PatchReturningWorker {
1045        base: PathBuf,
1046    }
1047
1048    #[async_trait]
1049    impl WorktreeAgent for PatchReturningWorker {
1050        async fn run_in(
1051            &self,
1052            req: &WorktreeAgentRequest<'_>,
1053        ) -> Result<AgentRunSummary, ForemanError> {
1054            // Remote side: a second checkout at the same base commit.
1055            let remote = tempfile::tempdir().unwrap();
1056            let clone = remote.path().join("checkout");
1057            let out = Command::new("git")
1058                .args(["clone", "-q"])
1059                .arg(&self.base)
1060                .arg(&clone)
1061                .output()
1062                .unwrap();
1063            assert!(out.status.success(), "{out:?}");
1064            std::fs::write(clone.join("new.rs"), "pub fn added() {}\n").unwrap();
1065            std::fs::write(clone.join("seed.txt"), "edited\n").unwrap();
1066            let patch = super::super::harness::capture_patch(&clone)?;
1067
1068            // Orchestrator side: apply into the worktree that will be gated.
1069            super::super::harness::git_apply(req.cwd, &patch)?;
1070            Ok(AgentRunSummary {
1071                answer: "remote".into(),
1072            })
1073        }
1074    }
1075
1076    #[tokio::test]
1077    async fn a_patch_made_on_another_checkout_lands_in_the_gated_worktree() {
1078        // The claim the whole remote protocol rests on: a worker never touches
1079        // the orchestrator's tree, and its patch still arrives there intact —
1080        // new files and edits to tracked files alike.
1081        let dir = worktree();
1082        let cwd = dir.path().to_path_buf();
1083        let pool = FleetPool::new(vec![FleetWorker::remote(
1084            "studio",
1085            Arc::new(PatchReturningWorker { base: cwd.clone() }),
1086            1,
1087        )]);
1088        let s = Subtask::files_only("s", "s", vec![]);
1089        pool.run_in(&request(&s, &cwd)).await.expect("applied");
1090
1091        assert_eq!(
1092            std::fs::read_to_string(cwd.join("seed.txt")).unwrap(),
1093            "edited\n",
1094            "the peer's edit to a tracked file arrived"
1095        );
1096        assert!(cwd.join("new.rs").exists(), "and so did its new file");
1097        let placement = &pool.placements()[0];
1098        assert_eq!(placement.worker_id.as_deref(), Some("studio"));
1099        assert!(placement.remote);
1100    }
1101
1102    #[test]
1103    fn a_zero_capacity_worker_is_clamped_rather_than_deadlocking() {
1104        let pool = FleetPool::new(vec![FleetWorker::local(
1105            "here",
1106            Arc::new(ExpectsCleanTree),
1107            0,
1108        )]);
1109        assert_eq!(pool.capacity(), 1);
1110        assert_eq!(pool.worker_ids(), vec!["here"]);
1111    }
1112}