Skip to main content

choir_queue/
lib.rs

1//! L2 speculative merge queue (DECISIONS.md D5).
2//!
3//! Dependent speculative pipeline: changes are tested in parallel against the
4//! speculative future state produced by everything queued ahead of them,
5//! "exactly as if they had been tested one at a time." Window sizing follows
6//! a TCP-flow-control-inspired algorithm: start at 20,
7//! +1 per successful merge, halved per failure.
8//!
9//! Conflict policy (DECISIONS.md): a change whose merge conflicts is evicted as
10//! a first-class conflict for its author to resolve; it is never silently
11//! resolved and never blocks the changes behind it, which are retested
12//! against a speculative state without it.
13//!
14//! CI runs behind [`executor::CiExecutor`] (the CI executor seam, D18).
15//! A verdict is four cases rather than a bool, because the queue's
16//! response to a test failure and to a provider fault must differ: only
17//! [`executor::Verdict::Failed`] ejects a change, and ejection takes
18//! everything that transitively depends on it. Tests use
19//! [`executor::Synthetic`]; [`local::LocalRunner`] runs real
20//! subprocesses; production wires the spindle/Firecracker executor.
21//!
22//! # Where this sits
23//!
24//! `docs/architecture.md` is the map of the whole workspace.
25//! This crate is L2, the speculative merge queue (D5).
26//!
27//! It builds on [`choir_hash`], [`choir_merge`], [`choir_oplog`], [`choir_sequencer`], [`choir_store`] and [`choir_view`].
28
29pub mod blast;
30pub mod conform;
31pub mod corpus;
32pub mod differential;
33pub mod differential_ledger;
34pub mod envelope;
35pub mod executor;
36pub mod git;
37pub mod identity;
38pub mod landing;
39pub mod local;
40pub mod memory;
41pub mod remote;
42pub mod speculate;
43pub mod worktree;
44
45use choir_merge::Pipeline;
46use choir_oplog::MemLog;
47use choir_sequencer::Sequencer;
48use speculate::{Speculator, Step};
49
50/// Default speculation window: both the initial size and the ceiling
51/// growth may not pass (a documented default).
52///
53/// It is a ceiling and not only a starting point because the cost model
54/// quoted everywhere else depends on it being one. A round's fleet cost
55/// is about `1 + p*k/2` and its tail about `1 + p*k`, and both are
56/// unbounded statements unless `k` is. Additive increase still runs --
57/// it is what walks the window back up to this ceiling after a
58/// multiplicative decrease -- it just cannot walk past it. TCP calls the
59/// same thing a maximum congestion window; a controller with only the
60/// increase half is not flow control, it is a ramp.
61///
62/// [`MergeQueue::set_max_window`] raises or lowers it per queue.
63pub const DEFAULT_WINDOW: usize = 20;
64
65/// A change submitted to the queue: a full-file edit carrying its own base.
66///
67/// The change's `base` is the content it was authored against (its parent),
68/// which is what the 3-way merge must use; merging against the queue's
69/// advanced tip as base would misread the proposal as reverting work merged
70/// ahead of it. The single-file model is the Phase-0 abstraction; the real
71/// tree diff arrives with the L1 integration.
72#[derive(Debug, Clone)]
73pub struct Change {
74    /// Stable change identifier.
75    pub id: u64,
76    /// Workspace (agent) that produced the change.
77    pub workspace: String,
78    /// The content this change was authored against (its parent state).
79    pub base: String,
80    /// The file content this change proposes.
81    pub proposed: String,
82    /// Ids of queued changes this one declares it depends on.
83    /// Declared-only — the queue never infers dependencies
84    /// from overlap; inference is a separate decision. Empty (the
85    /// default for all existing traffic) keeps the legacy
86    /// halve-and-retest behavior on failure; see [`MergeQueue::drain`].
87    pub depends: Vec<u64>,
88}
89
90/// Why a change left the queue without merging.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum Rejection {
93    /// Merge produced a first-class conflict; author must resolve and resubmit.
94    Conflict,
95    /// CI failed for this change on its speculative state.
96    CiFailure,
97    /// A strategy resolved, but its output edits the speculative state beyond
98    /// what the change proposed: silently reverted
99    /// or injected lines. Treated like a conflict — evicted first-class,
100    /// never landed, never blocking the train — but reported separately
101    /// because the author's change may be fine and the *strategy* at fault.
102    SafetyViolation {
103        /// The strategy whose resolution violated the invariant.
104        strategy: &'static str,
105        /// The unattributable lines, as evidence for escalation.
106        violation: choir_merge::safety::Violation,
107    },
108    /// Ejected because it (transitively) declared a dependency on a
109    /// change whose combined build failed. Not a verdict
110    /// on this change itself: resubmit once the dependency is fixed.
111    DependencyEjection {
112        /// The CI-failing change this one depends on.
113        on: u64,
114    },
115    /// A change with this [`identity::change_identity`] already landed
116    /// through this queue: the resubmission — typically
117    /// the same edit rebased after the train rewrote the tip — is
118    /// refused without re-merging, so it cannot land twice.
119    AlreadyLanded,
120}
121
122/// Outcome of draining a queue.
123#[derive(Debug)]
124pub struct QueueReport {
125    /// Changes merged, in merge order.
126    pub merged: Vec<u64>,
127    /// Changes rejected, with reasons.
128    pub rejected: Vec<(u64, Rejection)>,
129    /// Final repository state after all merges.
130    pub final_state: String,
131    /// Window size after each head decision (for observing TCP dynamics).
132    pub window_trace: Vec<usize>,
133    /// Total CI executions, including retests behind failures.
134    pub ci_runs: usize,
135    /// Total strategy-pipeline invocations. A change resolved from
136    /// [`memory::ResolutionMemory`] does not invoke the pipeline, which
137    /// is what a test counts to prove a replay happened.
138    pub merge_invocations: usize,
139    /// Changes whose conflict was resolved from memory, in train order.
140    /// Every one of them still ran CI: replay produces a candidate,
141    /// never a landing.
142    pub replayed: Vec<u64>,
143    /// Why the drain stopped early without blaming a change (D18).
144    ///
145    /// Set when the executor produced no verdict, or an inconclusive
146    /// one: a provider that could not be reached, a batch whose
147    /// verdict count did not match its jobs, a job that errored or
148    /// timed out. Everything not landed is back in the queue, in
149    /// order, and nothing was rejected on account of it -- which is the
150    /// distinction the old `bool` seam could not make.
151    pub provider_error: Option<String>,
152    /// Check reports the sequencer refused, verbatim (D49).
153    ///
154    /// Empty unless [`MergeQueue::set_check_reporter`] was called. A
155    /// refused report is not a landing decision -- the merge already
156    /// happened or did not on the verdict itself -- but silence about
157    /// it would leave the log missing checks with nothing saying so,
158    /// which is the shape of a bug nobody finds.
159    pub unreported_checks: Vec<String>,
160}
161
162/// Submits one check report, returning the sequencer's refusal if any.
163///
164/// Unsigned, like every other op this queue submits: the queue is an
165/// in-process component of whoever runs it, not a network client with
166/// an identity of its own. A daemon whose policy demands a signature
167/// will refuse these, and that refusal is reported rather than
168/// swallowed -- see [`QueueReport::unreported_checks`].
169fn record_check(
170    handle: &choir_sequencer::SequencerHandle,
171    reporter: &CheckReporter,
172    job: &executor::Job,
173    status: choir_view::CheckStatus,
174    evidence: &str,
175) -> Result<(), String> {
176    let op = choir_view::ViewOp::new(choir_view::OpKind::RecordCheck {
177        subject: job.subject.clone(),
178        name: reporter.name.clone(),
179        status,
180        evidence: evidence.to_string(),
181        reporter: reporter.channel.clone(),
182        target_ref: reporter.target_ref.clone(),
183    });
184    let (payload, sig) = match &reporter.seal {
185        Some(seal) => seal(&reporter.channel, &op),
186        None => (serde_json::to_vec(&op).expect("a ViewOp serializes"), None),
187    };
188    handle
189        .try_submit(&reporter.channel, payload, sig)
190        .map(|_| ())
191}
192
193/// Single-shard speculative merge queue over one file.
194pub struct MergeQueue {
195    base: String,
196    /// How a change is put on top of a state (D5). The text
197    /// implementation is the default; a caller holding a repository
198    /// installs the git one.
199    speculator: Box<dyn Speculator>,
200    window: usize,
201    /// The ceiling `window` may not grow past. Enforced in one place,
202    /// [`MergeQueue::resize`], so that a future growth path cannot
203    /// bypass it by being written somewhere else.
204    max_window: usize,
205    queue: std::collections::VecDeque<Change>,
206    memory: memory::ResolutionMemory,
207    landed: std::collections::BTreeSet<String>,
208    /// Where window changes are reported. Derived data: the queue never
209    /// reads it back, and a journal that dropped every record would
210    /// change no landing decision.
211    journal: Box<dyn choir_sequencer::journal::Journal>,
212    /// What to run for each candidate state (D18).
213    template: JobTemplate,
214    /// How a landing is written into the log (D68). The in-memory
215    /// workspace landing is the default; a caller whose log is the
216    /// source of truth installs one that moves the thing the change
217    /// was proposed to.
218    landing: Box<dyn landing::Landing>,
219    /// Who reports check results, when anybody does. `None` is the
220    /// default and means the queue keeps its verdicts to itself.
221    reporter: Option<CheckReporter>,
222}
223
224/// How the queue writes what CI found into the log (D49).
225///
226/// Off by default, and opt-in for the same reason
227/// [`MergeQueue::set_journal`] is: the queue is a library, the identity
228/// under which a check is reported belongs to whoever is running it,
229/// and inventing a channel name here would put an unattributable
230/// reporter in a signed, ordered record.
231#[derive(Clone)]
232pub struct CheckReporter {
233    /// The channel the check is reported under.
234    pub channel: String,
235    /// The check's name, e.g. `"ci/build"`.
236    pub name: String,
237    /// The ref these subjects are proposed to land on, in the view's
238    /// `<repo>:<refname>` form.
239    ///
240    /// `None` leaves the check node-wide. A commit id names no
241    /// repository, so an unbound check is readable only by node-wide
242    /// readers -- correct, and useless to the repository it is about.
243    pub target_ref: Option<String>,
244    /// How a report is turned into what this log will accept (D68).
245    ///
246    /// `None` submits the op's own bytes unsigned, which is right for a
247    /// queue whose sequencer admits them. A daemon's policy does not:
248    /// it demands a signature, and with `--require-scope` a scope
249    /// naming its log and a recent head. Both live in the payload, so
250    /// this seals the whole submission rather than only signing it --
251    /// a signer alone would sign bytes the policy then rejected for
252    /// their scope, and the refusal would read as a key problem.
253    pub seal: Option<Seal>,
254}
255
256/// Turns a report op into the payload and signature a log will accept.
257///
258/// Shared rather than owned because the queue clones its reporter per
259/// verdict, and `Arc` is what lets one node key back every clone
260/// without the key itself being copied.
261pub type Seal = std::sync::Arc<
262    dyn Fn(&str, &choir_view::ViewOp) -> (Vec<u8>, Option<choir_oplog::Witness>) + Send + Sync,
263>;
264
265impl std::fmt::Debug for CheckReporter {
266    /// Hand-written because [`Seal`] is a function and has no useful
267    /// debug form. It is reported as present or absent, which is the
268    /// part a reader chasing a refused report needs.
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("CheckReporter")
271            .field("channel", &self.channel)
272            .field("name", &self.name)
273            .field("target_ref", &self.target_ref)
274            .field("sealed", &self.seal.is_some())
275            .finish()
276    }
277}
278
279/// How the queue turns a speculative state into a [`executor::Job`].
280///
281/// The queue knows which tree to test; it does not know what "test"
282/// means for a repository, and inventing a default command would make
283/// the wrong one silent. The default template has no command, which
284/// every real executor answers with [`executor::Verdict::Errored`] --
285/// loud, and never mistaken for a change that failed.
286///
287/// A template deliberately cannot name a working directory (D18).
288/// Every member of a batch is tested against a *different* speculative
289/// state, so one directory shared by the batch would test the last
290/// state repeatedly -- well-formed, index-aligned, and wrong. The jobs
291/// this builds therefore leave [`executor::Job::directory`] unset,
292/// which asks the executor to materialize [`executor::Job::subject`]
293/// instead, the way [`crate::worktree::WorktreeRunner`] does for a git
294/// state. It is an invariant of the train rather than a knob, because
295/// there is no setting of it that would be right.
296#[derive(Debug, Clone, Default)]
297pub struct JobTemplate {
298    /// The command, as argv.
299    pub command: Vec<String>,
300    /// Exactly what the child sees.
301    pub environment: std::collections::BTreeMap<String, String>,
302    /// Wall-clock ceiling per job. `None` uses
303    /// [`executor::DEFAULT_DEADLINE`].
304    pub deadline: Option<std::time::Duration>,
305    /// Whether these jobs may write a shared build cache.
306    pub may_write_cache: bool,
307}
308
309impl JobTemplate {
310    /// The job testing `change` applied on the state named by `subject`.
311    ///
312    /// The subject is supplied rather than computed because only the
313    /// [`Speculator`] knows how its states are named; see
314    /// [`Speculator::subject`].
315    #[must_use]
316    pub fn job_for(&self, change: &Change, subject: choir_hash::ContentHash) -> executor::Job {
317        let mut job = executor::Job::new(subject, self.command.clone());
318        job.label = change.id.to_string();
319        job.environment = self.environment.clone();
320        job.may_write_cache = self.may_write_cache;
321        if let Some(d) = self.deadline {
322            job.deadline = d;
323        }
324        job
325    }
326}
327
328impl MergeQueue {
329    /// Moves the speculation window and records why.
330    ///
331    /// A window that shrank is the queue's loudest signal, and a size
332    /// alone cannot say whether it shrank because a build failed or
333    /// grew because a train landed. The cause travels with the number
334    /// so the answer does not have to be inferred from timing.
335    fn resize(&mut self, to: usize, cause: &str) {
336        let from = self.window;
337        // The clamp lives here rather than at the call site because the
338        // call site is a loop body that runs once per landed change: a
339        // ceiling checked anywhere else is a ceiling one caller can
340        // forget. A resize to the size it already is records nothing,
341        // so a queue sitting at the ceiling stays quiet in the journal.
342        self.window = to.min(self.max_window);
343        let to = self.window;
344        if from != to {
345            self.journal
346                .record(choir_sequencer::journal::Event::WindowResize {
347                    from,
348                    to,
349                    cause: cause.to_string(),
350                });
351        }
352    }
353
354    /// Sets the ceiling the speculation window may grow to, and brings
355    /// the current window under it now rather than at the next resize.
356    ///
357    /// The cost of a round is linear in this number and so is the
358    /// number of CI jobs in flight at once, which is why it is a
359    /// deliberate call and not a field a caller can drift upward.
360    ///
361    /// # Panics
362    ///
363    /// If `n` is zero, which would be a queue that speculates on
364    /// nothing and drains forever.
365    pub fn set_max_window(&mut self, n: usize) {
366        assert!(
367            n > 0,
368            "a window of zero would take no change from the queue"
369        );
370        self.max_window = n;
371        if self.window > n {
372            self.resize(n, "max window lowered");
373        }
374    }
375
376    /// The ceiling [`MergeQueue::window`] may grow to.
377    #[must_use]
378    pub fn max_window(&self) -> usize {
379        self.max_window
380    }
381
382    /// Sends window changes to `journal` instead of discarding them.
383    pub fn set_journal(&mut self, journal: Box<dyn choir_sequencer::journal::Journal>) {
384        self.journal = journal;
385    }
386
387    /// Records every verdict CI returns as a [`choir_view::OpKind::RecordCheck`] op.
388    ///
389    /// Every verdict, not only the faults. The map from
390    /// [`executor::Verdict`] to [`choir_view::CheckStatus`] is total, so recording
391    /// some and dropping the rest would put an arbitrary hole in the
392    /// log: a reader finding no check could not tell "it passed" from
393    /// "nobody was reporting". The subject is the speculative tree the
394    /// job actually ran against, which means one change tested at two
395    /// train positions reports against two subjects -- correct, because
396    /// they are two different questions, and the second is the one that
397    /// landed.
398    pub fn set_check_reporter(&mut self, reporter: CheckReporter) {
399        self.reporter = Some(reporter);
400    }
401
402    /// Creates a queue over `base` content with the default window.
403    pub fn new(base: &str) -> Self {
404        Self::with_pipeline(base, Pipeline::default_v1())
405    }
406
407    /// Creates a queue with a caller-supplied strategy pipeline — the D4/D19
408    /// widening seam (structured or LLM slots appended by the caller). Every
409    /// resolution is still safety-checked by
410    /// [`speculate::TextSpeculator`], which is what makes the
411    /// non-deterministic slots admissible at all.
412    pub fn with_pipeline(base: &str, pipeline: Pipeline) -> Self {
413        Self::with_speculator(base, Box::new(speculate::TextSpeculator::new(pipeline)))
414    }
415
416    /// Creates a queue over `base` whose merges, change identities and
417    /// job subjects come from `speculator` (D5).
418    ///
419    /// This is the constructor a caller holding a repository wants:
420    /// `base` is then a commit id rather than a file body, and every
421    /// state the queue moves around is one too. The queue's policy is
422    /// unchanged, which is the point — it was never about text.
423    pub fn with_speculator(base: &str, speculator: Box<dyn Speculator>) -> Self {
424        Self {
425            base: base.to_string(),
426            speculator,
427            window: DEFAULT_WINDOW,
428            max_window: DEFAULT_WINDOW,
429            reporter: None,
430            queue: std::collections::VecDeque::new(),
431            memory: memory::ResolutionMemory::new(),
432            landed: std::collections::BTreeSet::new(),
433            journal: Box::new(choir_sequencer::journal::NullJournal),
434            template: JobTemplate::default(),
435            landing: Box::new(landing::WorkspaceLanding),
436        }
437    }
438
439    /// Installs the job template (D18): what to run for each candidate
440    /// state. Without one, every job is refused by the executor for
441    /// having no command.
442    pub fn set_job_template(&mut self, template: JobTemplate) {
443        self.template = template;
444    }
445
446    /// Installs how a landing is recorded (D68).
447    ///
448    /// The default records a workspace head over whatever log the
449    /// sequencer holds, which is right for a caller mirroring a
450    /// canonical upstream. A caller whose own log decides -- a node --
451    /// installs one that moves the ref the change was proposed to, and
452    /// signs it, because the daemon's policy refuses an unsigned op.
453    pub fn set_landing(&mut self, landing: Box<dyn landing::Landing>) {
454        self.landing = landing;
455    }
456
457    /// Lands `prefix` in order, stopping at the first refusal.
458    ///
459    /// Returns the refusal, leaving everything not landed in `prefix`
460    /// with the refused change at its front. The caller requeues them:
461    /// a landing the log would not take means the base the rest of the
462    /// train was speculating on is not the base the log has, so the
463    /// round is void rather than the change being at fault.
464    fn land_prefix(
465        &mut self,
466        handle: &choir_sequencer::SequencerHandle,
467        prefix: &mut Vec<(Change, String)>,
468        merged: &mut Vec<u64>,
469        cause: &str,
470    ) -> Option<String> {
471        while !prefix.is_empty() {
472            let (change, state) = prefix.remove(0);
473            let subject = self.speculator.subject(&state);
474            if let Err(why) = self.landing.record(handle, &change, subject) {
475                prefix.insert(0, (change, state));
476                return Some(format!("landing refused: {why}"));
477            }
478            self.base = state;
479            self.landed.insert(self.speculator.identity(&change));
480            merged.push(change.id);
481            self.resize(self.window + 1, cause);
482        }
483        None
484    }
485
486    /// Installs a resolution memory (item B): a conflict whose triple it
487    /// remembers is replayed as a candidate instead of re-conflicting.
488    /// The default is an empty memory, which changes nothing.
489    pub fn set_memory(&mut self, memory: memory::ResolutionMemory) {
490        self.memory = memory;
491    }
492
493    /// Seeds a landed change identity (item 4), for a queue picking up
494    /// where an earlier instance left off. `drain` records identities of
495    /// everything it lands through the same set.
496    pub fn mark_landed(&mut self, identity: String) {
497        self.landed.insert(identity);
498    }
499
500    /// Enqueues a change.
501    pub fn submit(&mut self, change: Change) {
502        self.queue.push_back(change);
503    }
504
505    /// Number of changes waiting.
506    pub fn len(&self) -> usize {
507        self.queue.len()
508    }
509
510    /// Whether the queue is empty.
511    pub fn is_empty(&self) -> bool {
512        self.queue.is_empty()
513    }
514
515    /// The current speculation window.
516    ///
517    /// Exposed because [`QueueReport::window_trace`] cannot answer for
518    /// it in every case: a drain that stops on a provider fault breaks
519    /// out before writing the trace, so a test reading only the report
520    /// asserts over an empty vector and passes whatever the window did.
521    /// One did, until a mutation that halved the window on a fault was
522    /// not caught.
523    #[must_use]
524    pub fn window(&self) -> usize {
525        self.window
526    }
527
528    /// Drains through a sequencer over an in-memory log.
529    ///
530    /// For a caller whose repository is not the platform's: the
531    /// sequencer is not optional -- every landing is recorded through
532    /// it, which is what keeps the single-writer order true of a train
533    /// as well as of a push -- but a forge bridge mirrors an upstream
534    /// that is canonical (D21), so the ordering of one round is not a
535    /// claim anybody reads back. Keeping it in memory says that,
536    /// instead of writing a log which would look like a second source
537    /// of truth.
538    pub fn drain_in_memory(&mut self, ci: &mut dyn executor::CiExecutor) -> QueueReport {
539        let sequencer = Sequencer::spawn(Box::new(MemLog::new()));
540        let report = self.drain(ci, &sequencer);
541        sequencer.shutdown();
542        report
543    }
544
545    /// Drains the queue: speculatively merges up to `window` changes, runs CI
546    /// on each against its speculative state, merges the passing prefix, and
547    /// halves the window + retests behind on any failure.
548    ///
549    /// Every merged change is recorded through the single-writer `sequencer`
550    /// (payload = the merged state), preserving the platform's total order.
551    pub fn drain(
552        &mut self,
553        ci: &mut dyn executor::CiExecutor,
554        sequencer: &Sequencer,
555    ) -> QueueReport {
556        let mut merged = Vec::new();
557        let mut rejected = Vec::new();
558        let mut window_trace = Vec::new();
559        let mut ci_runs = 0usize;
560        let mut merge_invocations = 0usize;
561        let mut replayed = Vec::new();
562        let mut provider_error: Option<String> = None;
563        let mut unreported_checks: Vec<String> = Vec::new();
564        let handle = sequencer.handle();
565
566        while !self.queue.is_empty() {
567            // Build the speculative train: up to `window` changes, each merged
568            // onto the state produced by the changes ahead of it.
569            let take = self.window.min(self.queue.len());
570            let mut train: Vec<(Change, String)> = Vec::with_capacity(take);
571            let mut speculative = self.base.clone();
572            let mut train_rejects: Vec<(u64, Rejection)> = Vec::new();
573            for _ in 0..take {
574                let change = self.queue.pop_front().unwrap();
575                // Stable change identity (item 4): a resubmission of an
576                // already-landed change — the same position-independent
577                // edit, however rebased — is refused before any merge
578                // work, so the train neither re-merges nor duplicates it.
579                if self.landed.contains(&self.speculator.identity(&change)) {
580                    train_rejects.push((change.id, Rejection::AlreadyLanded));
581                    continue;
582                }
583                // Resolution memory (item B): a remembered triple is
584                // replayed without re-invoking the strategy pipeline.
585                // The replay is a *candidate* — it joins the train and
586                // runs the same CI verdict as everything else, and it
587                // can only exist because an author already committed
588                // this exact resolution as a value (item A's link).
589                if let Some(remembered) =
590                    self.memory
591                        .recall(&change.base, &speculative, &change.proposed)
592                {
593                    let next = remembered.to_string();
594                    speculative = next.clone();
595                    replayed.push(change.id);
596                    train.push((change, next));
597                    continue;
598                }
599                merge_invocations += 1;
600                match self
601                    .speculator
602                    .step(&change.base, &speculative, &change.proposed)
603                {
604                    Step::Advanced(next) => {
605                        speculative = next.clone();
606                        train.push((change, next));
607                    }
608                    Step::Conflict => {
609                        // First-class conflict: evict, do not block the train.
610                        train_rejects.push((change.id, Rejection::Conflict));
611                    }
612                    Step::Unsafe {
613                        strategy,
614                        violation,
615                    } => {
616                        train_rejects.push((
617                            change.id,
618                            Rejection::SafetyViolation {
619                                strategy,
620                                violation,
621                            },
622                        ));
623                    }
624                    Step::Unavailable(why) => {
625                        // Our fault, not this change's. It goes back at
626                        // the head of the queue ahead of the train
627                        // members below, so the order the caller
628                        // submitted survives the stall.
629                        provider_error =
630                            Some(format!("speculator `{}`: {why}", self.speculator.name()));
631                        self.queue.push_front(change);
632                        break;
633                    }
634                }
635            }
636            rejected.extend(train_rejects);
637            if provider_error.is_some() {
638                // Nothing here was tested, so nothing here is evidence.
639                // The window is not halved for the same reason it is
640                // not halved on a provider fault: halving answers
641                // changes that failed, and none of these did.
642                for (change, _) in train.into_iter().rev() {
643                    self.queue.push_front(change);
644                }
645                break;
646            }
647
648            // "Assume-pass": CI for every train member runs against its own
649            // speculative state, so every member costs a run even when an
650            // earlier member fails (its result is then discarded). The whole
651            // train goes to the executor in one call, which is what lets a
652            // provider be concurrent -- the one-job-at-a-time signature this
653            // replaced made the parallelism this cost model assumes
654            // impossible to implement, at any window size (D18).
655            let jobs: Vec<executor::Job> = train
656                .iter()
657                .map(|(change, state)| {
658                    self.template
659                        .job_for(change, self.speculator.subject(state))
660                })
661                .collect();
662            ci_runs += jobs.len();
663            let verdicts = match ci.run(&jobs) {
664                Ok(v) if v.len() == jobs.len() => v,
665                // Index alignment is the whole contract of the batch call.
666                // A provider that returns a different count has attributed
667                // somebody's result to somebody else, and no verdict in the
668                // batch can be trusted.
669                Ok(v) => {
670                    provider_error = Some(format!(
671                        "executor returned {} verdicts for {} jobs",
672                        v.len(),
673                        jobs.len()
674                    ));
675                    Vec::new()
676                }
677                Err(e) => {
678                    provider_error = Some(e.to_string());
679                    Vec::new()
680                }
681            };
682            // What CI said, written down before the queue acts on it:
683            // the record is the executor's answer, not the queue's
684            // response to it. An outage judged nobody, so every job in
685            // the batch gets the same `Errored` for the same reason --
686            // a reader asking what happened to their change gets an
687            // answer instead of an absence.
688            if let Some(rep) = self.reporter.clone() {
689                let reports: Vec<(choir_view::CheckStatus, String)> = match &provider_error {
690                    Some(why) => std::iter::repeat_n(
691                        (choir_view::CheckStatus::Errored, why.clone()),
692                        jobs.len(),
693                    )
694                    .collect(),
695                    None => verdicts
696                        .iter()
697                        .map(|v| (v.as_check_status(), v.to_string()))
698                        .collect(),
699                };
700                for (job, (status, evidence)) in jobs.iter().zip(reports) {
701                    if let Err(why) = record_check(&handle, &rep, job, status, &evidence) {
702                        unreported_checks.push(why);
703                    }
704                }
705            }
706
707            if provider_error.is_some() {
708                // No verdicts at all, so nothing here is evidence about any
709                // change. Requeue the whole train in order and stop. The
710                // window is deliberately not halved: halving is the response
711                // to changes that fail, and none of these did.
712                for (change, _) in train.into_iter().rev() {
713                    self.queue.push_front(change);
714                }
715                break;
716            }
717
718            // The first member that did not pass. Only a `Failed` may evict:
719            // ejection is permanent for the change *and* everything that
720            // transitively depends on it, so it is reserved for a verdict
721            // that is actually about the change's content. An inconclusive
722            // one stalls the train instead.
723            let stop_at = verdicts
724                .iter()
725                .position(|v| !matches!(v, executor::Verdict::Passed));
726            if let Some(i) = stop_at {
727                if !verdicts[i].evicts() {
728                    let mut prefix: Vec<(Change, String)> = train.drain(..i).collect();
729                    provider_error = Some(
730                        self.land_prefix(&handle, &mut prefix, &mut merged, "green prefix landed")
731                            .unwrap_or_else(|| verdicts[i].to_string()),
732                    );
733                    for (change, _) in train.into_iter().rev() {
734                        self.queue.push_front(change);
735                    }
736                    for (change, _) in prefix.into_iter().rev() {
737                        self.queue.push_front(change);
738                    }
739                    break;
740                }
741            }
742            let failure_at = stop_at;
743
744            match failure_at {
745                None => {
746                    // Whole train is green: merge it all.
747                    let mut prefix = train;
748                    if let Some(why) =
749                        self.land_prefix(&handle, &mut prefix, &mut merged, "train landed clean")
750                    {
751                        provider_error = Some(why);
752                        for (change, _) in prefix.into_iter().rev() {
753                            self.queue.push_front(change);
754                        }
755                        break;
756                    }
757                }
758                Some(i) => {
759                    // Merge the green prefix, reject the failure, requeue the
760                    // rest for retesting against a state without the failure.
761                    let mut prefix: Vec<(Change, String)> = train.drain(..i).collect();
762                    if let Some(why) =
763                        self.land_prefix(&handle, &mut prefix, &mut merged, "green prefix landed")
764                    {
765                        // The round is void, so nobody is blamed for it --
766                        // including the change CI failed on, which was
767                        // tested against a state the log has not accepted.
768                        provider_error = Some(why);
769                        for (change, _) in train.into_iter().rev() {
770                            self.queue.push_front(change);
771                        }
772                        for (change, _) in prefix.into_iter().rev() {
773                            self.queue.push_front(change);
774                        }
775                        break;
776                    }
777                    let (failed, _) = train.remove(0);
778                    let failed_id = failed.id;
779                    rejected.push((failed_id, Rejection::CiFailure));
780
781                    // Dependency-aware ejection: the failure ejects
782                    // exactly the failing change plus everything that
783                    // (transitively) declared a dependency on it, and the
784                    // window is not halved, because that blast radius is
785                    // named by the declarations rather than guessed by
786                    // shrinking the train. Survivors are requeued in
787                    // order and land in this same drain.
788                    //
789                    // The mode is chosen by whether anything actually
790                    // depends on *this* failure, which is why the set is
791                    // computed before the branch rather than after it.
792                    // Declarations are a lower bound on a blast radius
793                    // and never the whole of it, since an undeclared
794                    // semantic break is still possible; so a failure
795                    // nobody declared a dependency on leaves the queue
796                    // with no information about who else is bad, which is
797                    // the case halving exists for. The rule this replaces
798                    // asked whether declarations existed *anywhere* in
799                    // the train or the queue, so one change declaring one
800                    // dependency disabled halving for the whole drain.
801                    // `tests/it/cost.rs` is what measures the difference,
802                    // and under the old rule it read 4.81x against 1.64x
803                    // at a 10% failure rate scattered across the train,
804                    // with sixteen fewer changes landed at 25%.
805                    let mut ejected = std::collections::BTreeSet::from([failed_id]);
806                    loop {
807                        let dependent = |c: &Change| {
808                            !ejected.contains(&c.id)
809                                && c.depends.iter().any(|d| ejected.contains(d))
810                        };
811                        let next: Vec<u64> = train
812                            .iter()
813                            .map(|(c, _)| c)
814                            .chain(self.queue.iter())
815                            .filter(|c| dependent(c))
816                            .map(|c| c.id)
817                            .collect();
818                        if next.is_empty() {
819                            break;
820                        }
821                        ejected.extend(next);
822                    }
823                    if ejected.len() > 1 {
824                        for (change, _) in train.into_iter().rev() {
825                            if ejected.contains(&change.id) {
826                                rejected.push((
827                                    change.id,
828                                    Rejection::DependencyEjection { on: failed_id },
829                                ));
830                            } else {
831                                self.queue.push_front(change);
832                            }
833                        }
834                        let waiting = std::mem::take(&mut self.queue);
835                        for change in waiting {
836                            if ejected.contains(&change.id) {
837                                rejected.push((
838                                    change.id,
839                                    Rejection::DependencyEjection { on: failed_id },
840                                ));
841                            } else {
842                                self.queue.push_back(change);
843                            }
844                        }
845                    } else {
846                        self.resize((self.window / 2).max(1), "combined build failed");
847                        for (change, _) in train.into_iter().rev() {
848                            self.queue.push_front(change);
849                        }
850                    }
851                }
852            }
853            window_trace.push(self.window);
854        }
855
856        QueueReport {
857            merged,
858            rejected,
859            final_state: self.base.clone(),
860            window_trace,
861            ci_runs,
862            merge_invocations,
863            replayed,
864            provider_error,
865            unreported_checks,
866        }
867    }
868}
869
870/// Convenience: drain `changes` through a fresh queue + sequencer and return
871/// the report plus the sequencer's op count (which must equal merged count).
872pub fn run_batch(
873    base: &str,
874    changes: Vec<Change>,
875    ci: &mut dyn executor::CiExecutor,
876) -> (QueueReport, u64) {
877    let mut queue = MergeQueue::new(base);
878    for c in changes {
879        queue.submit(c);
880    }
881    let sequencer = Sequencer::spawn(Box::new(MemLog::new()));
882    let report = queue.drain(ci, &sequencer);
883    let log = sequencer.shutdown();
884    (report, log.len())
885}