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}