choir_bridge/queue.rs
1//! Queue-as-bot v0 (DECISIONS.md D21, queue stage): speculative merge
2//! trains, checked either by the host forge's CI or by our own (D18).
3//!
4//! v0 is verdict-only: it builds a train commit (base tip + each open
5//! PR merged in submission order, conflicts excluded), gets a verdict
6//! on it, then reports a per-PR verdict as a commit status. It never
7//! moves the protected branch on its own — landing the train is a
8//! separate, explicitly requested step.
9//!
10//! The check signal has two shapes. The forge one publishes the train
11//! as the `choir/train` branch and polls the forge's check API, which
12//! means the speculative merge of every open PR is pushed to a public
13//! remote and tested by someone else's runners. [`run_train_ci`] is the
14//! other: it hands the train to a [`choir_queue::executor::CiExecutor`],
15//! so the same train is checked here, on a provider we chose, and
16//! nothing speculative leaves the machine.
17//!
18//! Everything in this module is local git; the forge API glue lives in
19//! [`crate::github`] so the train mechanics stay testable offline.
20
21use choir_queue::differential_ledger::{effective_environment, CommandSpec};
22use choir_queue::executor::{CiExecutor, Job, Verdict, PROTOCOL};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::time::Duration;
26
27static DIFFERENTIAL_RUN_ID: AtomicU64 = AtomicU64::new(0);
28
29/// One PR's fate in a train build.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TrainEntry {
32 /// PR number (or any caller-chosen id in tests).
33 pub id: u64,
34 /// The PR head oid this entry was built from.
35 pub head: String,
36 /// Whether the merge onto the train succeeded.
37 pub merged: bool,
38 /// The speculative merge commit, absent when the PR conflicted.
39 pub merge: Option<String>,
40 /// Human-readable note (merge position or the conflict reason).
41 pub note: String,
42 /// The PR's every commit is already in the train by patch identity
43 /// (`git patch-id --stable` equivalence via
44 /// `git cherry`): the train rewrote or landed this change earlier,
45 /// so it was recognized rather than re-merged — a success, not a
46 /// conflict, despite `merged` being false.
47 pub already_landed: bool,
48}
49
50/// Result of building one speculative train.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Train {
53 /// Tip commit of the train (== base when nothing merged).
54 pub tip: String,
55 /// Per-PR outcomes, in build order.
56 pub entries: Vec<TrainEntry>,
57}
58
59/// Runs git in `dir` with a fixed bot identity, returning stdout.
60fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
61 let out = std::process::Command::new("git")
62 .arg("-c")
63 .arg("user.name=choir-queue")
64 .arg("-c")
65 .arg("user.email=queue@choir.invalid")
66 .arg("-c")
67 .arg("commit.gpgsign=false")
68 .args(args)
69 .current_dir(dir)
70 .env("GIT_TERMINAL_PROMPT", "0")
71 .output()
72 .map_err(|e| format!("spawn git: {e}"))?;
73 if out.status.success() {
74 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
75 } else {
76 Err(format!(
77 "git {args:?}: {}",
78 String::from_utf8_lossy(&out.stderr)
79 ))
80 }
81}
82
83/// Builds a speculative train in `repo` (a non-bare clone whose
84/// worktree this function owns): detaches at `base`, then merges each
85/// `(id, head)` in order with `--no-ff`. A conflicting PR is excluded
86/// (merge aborted) and the train continues without it.
87///
88/// # Errors
89///
90/// Git failures other than merge conflicts (missing oids, dirty repo).
91pub fn build_train(repo: &Path, base: &str, prs: &[(u64, String)]) -> Result<Train, String> {
92 git(repo, &["checkout", "-q", "--detach", base])?;
93 let mut entries = Vec::new();
94 let mut position = 0usize;
95 for (id, head) in prs {
96 // Stable change identity: a PR whose every commit
97 // is already in the train by patch identity — the train landed a
98 // rewritten form of it, or it was rebased in — is recognized,
99 // not re-merged. Checked against the current tip so a duplicate
100 // of an earlier train member is caught too.
101 if already_in(repo, "HEAD", head)? {
102 entries.push(TrainEntry {
103 id: *id,
104 head: head.clone(),
105 merged: false,
106 merge: None,
107 note: "already landed (patch identity)".to_string(),
108 already_landed: true,
109 });
110 continue;
111 }
112 let msg = format!("choir train: PR #{id}");
113 match git(repo, &["merge", "--no-ff", "-q", "-m", &msg, head]) {
114 Ok(_) => {
115 position += 1;
116 let merge = git(repo, &["rev-parse", "HEAD"])?.trim().to_string();
117 entries.push(TrainEntry {
118 id: *id,
119 head: head.clone(),
120 merged: true,
121 merge: Some(merge),
122 note: format!("train position {position}"),
123 already_landed: false,
124 });
125 }
126 Err(_) => {
127 // Conflict (or unmergeable): drop this PR from the train.
128 git(repo, &["merge", "--abort"]).ok();
129 entries.push(TrainEntry {
130 id: *id,
131 head: head.clone(),
132 merged: false,
133 merge: None,
134 note: "conflicts with train".to_string(),
135 already_landed: false,
136 });
137 }
138 }
139 }
140 let tip = git(repo, &["rev-parse", "HEAD"])?.trim().to_string();
141 Ok(Train { tip, entries })
142}
143
144/// Whether every commit of `head` is already contained in `upstream` by
145/// patch identity. `git cherry` marks a commit `-` when an equivalent
146/// change (same `git patch-id --stable`) is upstream, `+` when it is
147/// not; no `+` lines — including the empty output of a plain ancestor —
148/// means there is nothing left to merge.
149pub fn already_in(repo: &Path, upstream: &str, head: &str) -> Result<bool, String> {
150 let out = git(repo, &["cherry", upstream, head])?;
151 Ok(out.lines().all(|line| !line.starts_with('+')))
152}
153
154/// Structured advisory result returned by the separately built D23 runner.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct DifferentialOutcome {
157 /// Monotonic calibration-ledger observation id.
158 pub observation_id: u64,
159 /// Stable classifier spelling from the runner.
160 pub verdict: DifferentialVerdict,
161 /// Number of interaction flags still awaiting ground truth.
162 pub pending_interactions: u64,
163}
164
165/// Closed set of D23 classifications accepted from the runner.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum DifferentialVerdict {
168 /// Both parents and the merge passed.
169 Clean,
170 /// Both parents passed and the merge failed.
171 InteractionFailure,
172 /// At least one parent failed.
173 InconclusiveParentFailure,
174}
175
176impl DifferentialVerdict {
177 /// The ledger's stable spelling. CLI output prints this rather than the
178 /// Rust `Debug` name so every consumer — ledger rows, receipts, stdout —
179 /// parses one vocabulary; the two spellings once cost a downstream
180 /// summarizer a silent zero.
181 #[must_use]
182 pub const fn as_str(self) -> &'static str {
183 match self {
184 Self::Clean => "clean",
185 Self::InteractionFailure => "interaction_failure",
186 Self::InconclusiveParentFailure => "inconclusive_parent_failure",
187 }
188 }
189}
190
191fn compatible_confidence_policy(value: &serde_json::Value) -> bool {
192 value["format_version"].as_u64() == Some(1)
193 && value["method"].as_str() == Some("one_sided_exact_binomial_zero_spurious")
194 && value["confidence"]["numerator"].as_u64() == Some(95)
195 && value["confidence"]["denominator"].as_u64() == Some(100)
196 && value["target"]["numerator"].as_u64() == Some(1)
197 && value["target"]["denominator"].as_u64() == Some(1000)
198 && value["target"]["comparison"].as_str() == Some("strictly_less_than")
199 && value["minimum_evaluated_merges"].as_u64() == Some(2_995)
200 && value["requires_zero_spurious_failures"].as_bool() == Some(true)
201 && value["assumptions"].as_array().is_some_and(|assumptions| {
202 assumptions
203 == &[
204 serde_json::Value::String("independent_runs".to_string()),
205 serde_json::Value::String(
206 "representative_queue_command_and_merge_population".to_string(),
207 ),
208 ]
209 })
210}
211
212fn cleanup_worktrees(repo: &Path, root: &Path, paths: &[PathBuf]) -> Result<(), String> {
213 let mut first_error = None;
214 for path in paths.iter().rev() {
215 let path_text = path.to_string_lossy().into_owned();
216 if let Err(error) = git(repo, &["worktree", "remove", "--force", &path_text]) {
217 first_error.get_or_insert(error);
218 }
219 }
220 if root.exists() {
221 if let Err(error) = std::fs::remove_dir_all(root) {
222 first_error
223 .get_or_insert_with(|| format!("remove differential checkout root: {error}"));
224 }
225 }
226 if let Some(parent) = root.parent() {
227 std::fs::remove_dir(parent).ok();
228 }
229 first_error.map_or(Ok(()), Err)
230}
231
232/// Three revision worktrees whose lifetime is a whole **calibration run**
233/// rather than a single observation.
234///
235/// The observation cost the harness cannot avoid is compiling the project
236/// under test three times. The cost it *can* avoid is compiling the project's
237/// dependencies three times per observation, which is what a fresh worktree
238/// per observation forces: the operator's `--target-dir` is tree-relative (the
239/// ledger refuses any target directory that escapes its worktree), so
240/// destroying the tree destroys the build directory with it and the next
241/// observation starts from zero. Holding the same three trees open across
242/// observations and moving them with `git checkout` keeps those build
243/// directories alive. Measured on `rust-lang/log`, one run: **16.1 s** in a
244/// fresh tree against **12.0-13.4 s** in a held tree switched to a new
245/// revision.
246///
247/// Three properties are deliberately preserved, because each of them is load
248/// bearing for what the calibration claims:
249///
250/// - **Every run is still really run.** A held tree makes a run cheaper, never
251/// skipped: `cargo test` re-links and re-executes the test binaries even when
252/// nothing changed (measured: four test binaries execute in a fully warm
253/// tree). That is what the ledger's `independent_runs` assumption needs, and
254/// it is why this is not a result cache.
255/// - **The three trees stay separate.** Each keeps its own build directory, so
256/// the cross-tree artifact contamination that invalidated an earlier receipt
257/// cannot recur, and the three concurrent runs still cannot contend on one
258/// tool lock.
259/// - **Nothing survives the run.** The worktrees and their root are removed by
260/// [`Self::close`], and again by `Drop` if a caller returns early, so an
261/// operator's repository is left as it was found.
262///
263/// What it does change, stated rather than buried: an observation now starts in
264/// a tree that holds an *earlier* observation's untracked build output.
265/// Tracked content is exact — the checkout is forced, so the tree matches the
266/// revision — but a command that writes untracked files into its tree will see
267/// them again. Callers that need a pristine tree per observation still have
268/// one: [`run_differential`] opens and closes a session around a single
269/// observation, and `choir-bridge calibrate --fresh-worktrees` selects that
270/// path for a whole run.
271///
272/// Which tree plays which role is decided per observation: a revision is
273/// assigned to a tree that is already sitting on it, when one is. Replaying
274/// consecutive first-parent merges — the shape of a calibration corpus — makes
275/// this pay every observation, because a merge's first parent *is* the
276/// previous observation's merge: the tree that just ran that merge becomes the
277/// parent-a tree with a no-op checkout, so its command rebuilds nothing at
278/// all. Measured on `rust-lang/log`, one warm tree, solo: **8.9 s** for a
279/// no-op revision against **15.0 s** for a one-merge move.
280pub struct DifferentialSession {
281 repo: PathBuf,
282 root: PathBuf,
283 /// `None` until the first observation names the revisions to check out.
284 /// Worktree creation needs a revision, so there is nothing useful to
285 /// create at open time. Each entry pairs a worktree path with the
286 /// revision the tree currently holds, which is what role assignment
287 /// matches against.
288 trees: Option<[(PathBuf, String); 3]>,
289}
290
291impl DifferentialSession {
292 /// Names a session root under `repo`. Creates nothing until the first
293 /// observation; opening cannot fail.
294 #[must_use]
295 pub fn open(repo: &Path) -> Self {
296 Self {
297 repo: repo.to_path_buf(),
298 root: repo.join(".choir-differential").join(format!(
299 "run-{}-{}",
300 std::process::id(),
301 DIFFERENTIAL_RUN_ID.fetch_add(1, Ordering::Relaxed)
302 )),
303 trees: None,
304 }
305 }
306
307 /// Puts the three trees on the three requested revisions, creating them on
308 /// the first call and moving them on every later one.
309 fn prepare(
310 &mut self,
311 parent_a: &str,
312 parent_b: &str,
313 merged: &str,
314 ) -> Result<[PathBuf; 3], String> {
315 let revisions = [parent_a, parent_b, merged];
316 if let Some(trees) = &mut self.trees {
317 // A tree already holding a requested revision keeps it, so the
318 // checkout below is a no-op there and the command that follows
319 // rebuilds nothing. Roles are not pinned to trees: on a corpus of
320 // consecutive first-parent merges, parent a of this observation is
321 // the previous observation's merge, and this hands that revision
322 // its still-built tree. Unmatched roles take the leftover trees in
323 // order, which keeps the assignment stable when nothing matches.
324 let mut assigned = [usize::MAX; 3];
325 let mut used = [false; 3];
326 for (role, revision) in revisions.iter().enumerate() {
327 if let Some(index) =
328 (0..trees.len()).find(|&index| !used[index] && trees[index].1 == *revision)
329 {
330 assigned[role] = index;
331 used[index] = true;
332 }
333 }
334 for slot in &mut assigned {
335 if *slot == usize::MAX {
336 let index = used
337 .iter()
338 .position(|taken| !taken)
339 .expect("three roles cannot exhaust three trees");
340 *slot = index;
341 used[index] = true;
342 }
343 }
344 let mut paths = Vec::with_capacity(revisions.len());
345 for (role, revision) in revisions.iter().enumerate() {
346 let (path, head) = &mut trees[assigned[role]];
347 // `--force` even when the tree already holds the revision: the
348 // tree is the harness's own scratch checkout and an
349 // observation must start from exactly this revision's tracked
350 // content, so a command that dirtied a tracked file (a
351 // lockfile, say) must not be able to leak it into the next
352 // observation. It leaves untracked build output alone, which
353 // is the point of holding the tree at all.
354 git(path, &["checkout", "-q", "--detach", "--force", revision])?;
355 *head = (*revision).to_string();
356 paths.push(path.clone());
357 }
358 return Ok(paths
359 .try_into()
360 .expect("three roles produce three tree paths"));
361 }
362 std::fs::create_dir_all(&self.root)
363 .map_err(|error| format!("create differential checkout root: {error}"))?;
364 let mut created: Vec<(PathBuf, String)> = Vec::with_capacity(revisions.len());
365 // Neutral names: role assignment above may hand any tree to any role
366 // from the second observation on, so role-named directories would lie.
367 for (name, revision) in [
368 ("tree-0", parent_a),
369 ("tree-1", parent_b),
370 ("tree-2", merged),
371 ] {
372 let path = self.root.join(name);
373 let path_text = path.to_string_lossy().into_owned();
374 if let Err(error) = git(
375 &self.repo,
376 &["worktree", "add", "-q", "--detach", &path_text, revision],
377 ) {
378 let paths: Vec<PathBuf> = created.into_iter().map(|(path, _)| path).collect();
379 cleanup_worktrees(&self.repo, &self.root, &paths).ok();
380 return Err(error);
381 }
382 created.push((path, revision.to_string()));
383 }
384 let trees: [(PathBuf, String); 3] = created
385 .try_into()
386 .map_err(|_| "differential session needs exactly three worktrees".to_string())?;
387 let paths = [trees[0].0.clone(), trees[1].0.clone(), trees[2].0.clone()];
388 self.trees = Some(trees);
389 Ok(paths)
390 }
391
392 fn cleanup(&mut self) -> Result<(), String> {
393 let paths: Vec<PathBuf> = self
394 .trees
395 .take()
396 .into_iter()
397 .flatten()
398 .map(|(path, _)| path)
399 .collect();
400 cleanup_worktrees(&self.repo, &self.root, &paths)
401 }
402
403 /// Removes the three worktrees and the session root, reporting the first
404 /// failure. `Drop` repeats this as a best-effort backstop, so a caller that
405 /// returns early still leaves nothing behind — but only `close` can tell
406 /// the caller that cleanup failed.
407 ///
408 /// # Errors
409 ///
410 /// A worktree or the session root could not be removed.
411 pub fn close(mut self) -> Result<(), String> {
412 self.cleanup()
413 }
414}
415
416impl Drop for DifferentialSession {
417 fn drop(&mut self) {
418 // Best effort: `close` is the reporting path. This exists so an early
419 // return or a panic mid-run cannot leave `.choir-differential` in an
420 // operator's repository.
421 self.cleanup().ok();
422 }
423}
424
425/// Resolves a merge commit to its canonical oid and its exact first and second
426/// parents. Resolution happens before any checkout so an abbreviated or
427/// non-canonical spelling cannot reach the ledger.
428fn resolve_merge_revisions(repo: &Path, merge: &str) -> Result<(String, String, String), String> {
429 let commit = format!("{merge}^{{commit}}");
430 let merged_revision = git(repo, &["rev-parse", "--verify", &commit])?
431 .trim()
432 .to_string();
433 let first = format!("{merged_revision}^1");
434 let second = format!("{merged_revision}^2");
435 let parent_a = git(repo, &["rev-parse", &first])?.trim().to_string();
436 let parent_b = git(repo, &["rev-parse", &second])?.trim().to_string();
437 Ok((parent_a, parent_b, merged_revision))
438}
439
440/// Runs one observation in an already-open session's three worktrees.
441///
442/// This is the loop body of a calibration run: the session is opened once and
443/// reused, so the build directories the operator's command creates survive
444/// between observations. Everything about the result is unchanged — the same
445/// runner, the same argv, the same structured receipt validation as the
446/// single-observation [`run_differential`].
447///
448/// The result is always advisory. This function only validates and returns a
449/// closed structured result; callers have no landing-gate output to consume.
450/// The state directory and command file are explicit arguments, never
451/// environment configuration.
452///
453/// # Errors
454///
455/// The merge does not have two parents, a worktree cannot be created or moved
456/// to the requested revision, the runner fails operationally, or its structured
457/// result is malformed/claims that landing gating is enabled.
458pub fn run_differential_in(
459 session: &mut DifferentialSession,
460 merge: &str,
461 runner: &Path,
462 command_file: &Path,
463 state_dir: &Path,
464) -> Result<DifferentialOutcome, String> {
465 let (parent_a, parent_b, merged_revision) = resolve_merge_revisions(&session.repo, merge)?;
466 let trees = session.prepare(&parent_a, &parent_b, &merged_revision)?;
467 let output = std::process::Command::new(runner)
468 .arg("run")
469 .arg(command_file)
470 .arg(state_dir)
471 .arg(&parent_a)
472 .arg(&trees[0])
473 .arg(&parent_b)
474 .arg(&trees[1])
475 .arg(&merged_revision)
476 .arg(&trees[2])
477 .output()
478 .map_err(|error| format!("spawn differential runner: {error}"))?;
479 if !output.status.success() {
480 return Err("differential runner exited unsuccessfully".to_string());
481 }
482 let value: serde_json::Value = serde_json::from_slice(&output.stdout)
483 .map_err(|_| "differential runner returned malformed JSON".to_string())?;
484 if value["format_version"].as_u64() != Some(1)
485 || value["merge"].as_str() != Some(merged_revision.as_str())
486 || value["calibration"]["landing_gate_enabled"].as_bool() != Some(false)
487 || !matches!(
488 &value["calibration"]["confidence_claim"],
489 serde_json::Value::Null | serde_json::Value::Bool(_)
490 )
491 || !compatible_confidence_policy(&value["calibration"]["confidence_policy"])
492 {
493 return Err("differential runner returned an incompatible receipt".to_string());
494 }
495 let verdict = match value["report"]["verdict"].as_str() {
496 Some("clean") => DifferentialVerdict::Clean,
497 Some("interaction_failure") => DifferentialVerdict::InteractionFailure,
498 Some("inconclusive_parent_failure") => DifferentialVerdict::InconclusiveParentFailure,
499 _ => return Err("differential runner returned an unknown verdict".to_string()),
500 };
501 if !matches!(
502 &value["calibration"]["target"]["met"],
503 serde_json::Value::Null | serde_json::Value::Bool(_)
504 ) {
505 return Err("differential target verdict must be boolean or null".to_string());
506 }
507 Ok(DifferentialOutcome {
508 observation_id: value["observation_id"]
509 .as_u64()
510 .ok_or("differential result needs an observation id")?,
511 verdict,
512 pending_interactions: value["calibration"]["pending_interactions"]
513 .as_u64()
514 .ok_or("differential receipt needs pending_interactions")?,
515 })
516}
517
518/// Builds isolated worktrees for a speculative merge's two parents and the
519/// merge itself, then invokes the explicit `choir-differential` runner.
520///
521/// This is [`run_differential_in`] wrapped in a session of its own, so the
522/// three worktrees are created and destroyed around this one observation and
523/// the command sees a pristine tree. That is the right shape for a speculative
524/// train, where each merge is checked once, and it is the escape hatch for an
525/// operator re-checking a flagged interaction without the previous
526/// observation's build output present.
527///
528/// # Errors
529///
530/// As [`run_differential_in`], plus a failure to clean the worktrees up.
531pub fn run_differential(
532 repo: &Path,
533 merge: &str,
534 runner: &Path,
535 command_file: &Path,
536 state_dir: &Path,
537) -> Result<DifferentialOutcome, String> {
538 let mut session = DifferentialSession::open(repo);
539 let result = run_differential_in(&mut session, merge, runner, command_file, state_dir);
540 let cleanup = session.close();
541 match (result, cleanup) {
542 (Ok(value), Ok(())) => Ok(value),
543 (Err(error), _) | (Ok(_), Err(error)) => Err(error),
544 }
545}
546
547/// What one train verdict means for the PRs riding it.
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub struct TrainReport {
550 /// Whether the train may land. The only input to that decision, so
551 /// that neither check signal gets a landing rule of its own.
552 pub green: bool,
553 /// Commit-status state: `success`, `failure`, or `error`.
554 pub state: &'static str,
555 /// Commit-status description. Fixed strings, never anything an
556 /// executor or a pull request wrote — the Rule of Two applies to
557 /// what we say as well as to what we read.
558 pub description: &'static str,
559}
560
561/// Reads a train's verdict as the report its PRs get (D18, D49).
562///
563/// The four cases collapse to three states, and which two share one is
564/// the whole point. `failure` blames the change, and only
565/// [`Verdict::Failed`] may: an outage or a deadline posts `error`,
566/// because telling an author their work is red when our provider fell
567/// over is the confusion the seam exists to end. `green` is true for
568/// exactly one case, so a train never lands on a verdict that merely
569/// failed to say no.
570#[must_use]
571pub fn train_report(verdict: &Verdict) -> TrainReport {
572 match verdict {
573 Verdict::Passed => TrainReport {
574 green: true,
575 state: "success",
576 description: "speculative train green",
577 },
578 Verdict::Failed { .. } => TrainReport {
579 green: false,
580 state: "failure",
581 description: "train CI failed",
582 },
583 Verdict::Errored { .. } | Verdict::TimedOut => TrainReport {
584 green: false,
585 state: "error",
586 description: "train CI could not run",
587 },
588 }
589}
590
591/// The report for a train we could not get a verdict on at all.
592///
593/// Same shape as a provider fault, because it is one: an executor that
594/// could not be reached and an executor that failed to boot a VM are
595/// the same news to the change riding the train.
596#[must_use]
597pub fn train_unavailable() -> TrainReport {
598 train_report(&Verdict::Errored {
599 provider: String::new(),
600 detail: String::new(),
601 })
602}
603
604/// Checks one train commit with a [`CiExecutor`] instead of asking the
605/// host forge (D18).
606///
607/// The forge path and this one answer the same question by opposite
608/// means. Asking the forge requires publishing the speculative merge of
609/// every open PR to a remote and trusting whatever ran there; this
610/// checks the train in the worktree that just built it, with an
611/// operator-declared command, on a provider the operator picked. What
612/// comes back is a [`Verdict`] rather than a bool, so the caller can
613/// still tell "the train is bad" from "we could not find out" — the
614/// distinction the whole seam exists for, and one the forge's
615/// `status`/`conclusion` enums also make and the bool did not.
616///
617/// `repo` is checked out at `tip` first, forcibly: the train build owns
618/// this worktree and left it detached at the train it built, but an
619/// advisory differential run between the two may have moved it.
620///
621/// The job is content-addressed by `tip` under git's own codec, so the
622/// subject a cache keys on and the subject a `RecordCheck` names are
623/// the commit itself rather than a queue-local id. `may_write_cache`
624/// stays false: a train holds unreviewed code from every open PR, which
625/// is exactly the untrusted case the flag is for.
626///
627/// # Errors
628///
629/// Git could not resolve or check out `tip`, the executor could not be
630/// reached, or it answered with something other than one verdict for
631/// the one job. All three mean we did not find out, never that the
632/// train is bad — a caller must not land on this and must not blame a
633/// change for it.
634pub fn run_train_ci(
635 repo: &Path,
636 tip: &str,
637 spec: &CommandSpec,
638 ci: &mut dyn CiExecutor,
639) -> Result<Verdict, String> {
640 let oid = git(
641 repo,
642 &["rev-parse", "--verify", &format!("{tip}^{{commit}}")],
643 )?
644 .trim()
645 .to_string();
646 let subject = choir_hash::ContentHash::from_git_oid(&oid)
647 .ok_or_else(|| format!("train tip {oid} is not a git object id"))?;
648 git(repo, &["checkout", "-q", "--detach", "--force", &oid])?;
649
650 let mut command = Vec::with_capacity(spec.args.len() + 1);
651 command.push(spec.program.clone());
652 command.extend(spec.args.iter().cloned());
653 let mut job = Job::new(subject, command);
654 job.label = "train".to_string();
655 job.environment = effective_environment(&spec.env);
656 job.directory = Some(repo.to_path_buf());
657 if let Some(seconds) = spec.timeout_seconds {
658 job.deadline = Duration::from_secs(seconds);
659 }
660
661 // The handshake before the work, so a provider speaking another
662 // protocol is refused rather than believed.
663 let info = ci.info().map_err(|error| error.to_string())?;
664 if info.protocol != PROTOCOL {
665 return Err(format!(
666 "executor `{}` speaks protocol {} and this build speaks {PROTOCOL}",
667 info.name, info.protocol
668 ));
669 }
670 let verdicts = ci.run(&[job]).map_err(|error| error.to_string())?;
671 match verdicts.len() {
672 1 => Ok(verdicts.into_iter().next().expect("length checked")),
673 n => Err(format!(
674 "executor `{}` answered {n} times for one job",
675 info.name
676 )),
677 }
678}
679
680/// Runs the advisory detector for every merge commit in a train, preserving
681/// train order and returning operational errors per entry instead of turning
682/// them into a landing decision.
683#[must_use]
684pub fn run_train_differentials(
685 repo: &Path,
686 train: &Train,
687 runner: &Path,
688 command_file: &Path,
689 state_dir: &Path,
690) -> Vec<(u64, Result<DifferentialOutcome, String>)> {
691 train
692 .entries
693 .iter()
694 .filter(|entry| entry.merged)
695 .map(|entry| {
696 let result = entry
697 .merge
698 .as_deref()
699 .ok_or("merged train entry is missing its merge commit".to_string())
700 .and_then(|merge| run_differential(repo, merge, runner, command_file, state_dir));
701 (entry.id, result)
702 })
703 .collect()
704}
705
706/// Two-parent merge commits along `repo`'s first-parent history, newest
707/// first: the population `choir-bridge harvest` replays (D27).
708///
709/// Exactly two parents because the differential adapter seats exactly
710/// three worktrees — parent a, parent b, merged — so an octopus merge has
711/// no seat for its third parent and is enumerated past rather than failed
712/// on. First-parent order for the same reason it is load-bearing in
713/// choir-queue's corpus module: it lists the merges that landed on this
714/// branch and skips commits internal to the branches they merged, which
715/// is the population D23 cares about.
716///
717/// `limit` keeps only the most recent `limit` merges; 0 keeps them all.
718/// Replay order is the caller's: oldest-first pays best with a held
719/// [`DifferentialSession`], whose docs explain why.
720///
721/// # Errors
722///
723/// Git failing to spawn or exiting nonzero.
724pub fn harvestable_merges(repo: &Path, limit: usize) -> Result<Vec<String>, String> {
725 let log = git(
726 repo,
727 &["log", "--first-parent", "--merges", "--format=%H %P"],
728 )?;
729 let mut merges = parse_merge_list(&log);
730 if limit > 0 {
731 merges.truncate(limit);
732 }
733 Ok(merges)
734}
735
736/// Parses `git log --format="%H %P"` output into the ids of commits with
737/// exactly two parents, preserving order.
738///
739/// Pure, so the format contract is testable without a repository — the
740/// same parse/shell-out split choir-queue's corpus module uses.
741#[must_use]
742pub fn parse_merge_list(log: &str) -> Vec<String> {
743 log.lines()
744 .filter_map(|line| {
745 let mut fields = line.split_whitespace();
746 let id = fields.next()?;
747 (fields.count() == 2).then(|| id.to_string())
748 })
749 .collect()
750}
751
752/// Merge commits already recorded in `state_dir`'s observation ledger,
753/// keyed by the ledger row's `revisions.merged` oid.
754///
755/// This is what makes `choir-bridge harvest` incremental (D27): a re-run
756/// against an updated mirror consults the ledger it is about to extend and
757/// replays only the merges it has never seen. A missing or unreadable
758/// ledger is an empty set — the first harvest into a fresh state directory
759/// must not fail on its own absence — and a malformed row is skipped
760/// rather than trusted, so it can never suppress a replay.
761#[must_use]
762pub fn observed_merges(state_dir: &Path) -> std::collections::BTreeSet<String> {
763 std::fs::read_to_string(state_dir.join("observations.jsonl"))
764 .map(|text| parse_observed_merges(&text))
765 .unwrap_or_default()
766}
767
768/// Parses observation-ledger lines into the set of merged-revision oids.
769///
770/// Pure, so the ledger-row contract is testable without a state directory.
771#[must_use]
772pub fn parse_observed_merges(text: &str) -> std::collections::BTreeSet<String> {
773 text.lines()
774 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
775 .filter_map(|row| row["revisions"]["merged"].as_str().map(str::to_string))
776 .collect()
777}
778
779/// Whether a path cannot change what a build-and-test command observes.
780///
781/// Deliberately a short, conservative allowlist rather than a guess at what
782/// matters: documentation, licences, and forge bookkeeping. Everything else
783/// — including `Cargo.toml`, `build.rs`, and any `.txt` that might be a test
784/// fixture — counts as relevant, because the cost of wrongly skipping a
785/// merge is a semantic conflict that never enters the corpus, while the cost
786/// of wrongly keeping one is three builds.
787///
788/// # Examples
789///
790/// ```
791/// use choir_bridge::queue::path_is_inert;
792///
793/// // Documentation and forge bookkeeping cannot move a test result.
794/// assert!(path_is_inert("README.md"));
795/// assert!(path_is_inert("docs/operating/limits.md"));
796/// assert!(path_is_inert(".github/workflows/ci.yml"));
797///
798/// // Everything else counts, including the files that only look inert.
799/// assert!(!path_is_inert("src/lib.rs"));
800/// assert!(!path_is_inert("Cargo.toml"));
801/// assert!(!path_is_inert("tests/fixtures/input.txt"));
802/// ```
803#[must_use]
804pub fn path_is_inert(path: &str) -> bool {
805 if path.starts_with(".github/") {
806 return true;
807 }
808 let name = path.rsplit('/').next().unwrap_or(path);
809 if name.ends_with(".md") {
810 return true;
811 }
812 // `LICENSE-MIT` and `LICENSE-APACHE` are the usual dual-licence spelling,
813 // so the licence names match by prefix; the rest match exactly, because a
814 // prefix rule on them would swallow real source files.
815 if name.starts_with("LICENSE") || name.starts_with("LICENCE") {
816 return true;
817 }
818 let stem = name.split('.').next().unwrap_or(name);
819 matches!(stem, "COPYING" | "NOTICE" | "AUTHORS" | "CHANGELOG")
820 || matches!(name, ".gitignore" | ".gitattributes" | ".mailmap")
821}
822
823/// Whether every file this merge changed, against **either** parent, is
824/// inert by [`path_is_inert`].
825///
826/// A semantic conflict is an interaction between the two branches' changes,
827/// so the population that can possibly hold one is the union of the two
828/// parent diffs. A merge whose whole union is documentation cannot fail a
829/// build-and-test command in a way the parents pass, so harvesting it buys
830/// three builds' worth of nothing. Skipping it is a **population
831/// restriction**, not an optimization detail: callers record it rather than
832/// applying it silently, because it changes which merges the denominator
833/// counts.
834///
835/// An empty union — a merged tree identical to both parents — returns
836/// `false`: it is strange enough to be worth observing rather than assuming
837/// away.
838///
839/// # Errors
840///
841/// The merge cannot be resolved to two parents, or git fails.
842pub fn merge_changes_only_inert_paths(repo: &Path, merge: &str) -> Result<bool, String> {
843 let (parent_a, parent_b, merged) = resolve_merge_revisions(repo, merge)?;
844 let mut any = false;
845 for parent in [&parent_a, &parent_b] {
846 let diff = git(repo, &["diff", "--name-only", parent, &merged])?;
847 for path in diff.lines().map(str::trim).filter(|p| !p.is_empty()) {
848 any = true;
849 if !path_is_inert(path) {
850 return Ok(false);
851 }
852 }
853 }
854 Ok(any)
855}
856
857/// Records, beside the ledger, every way a harvest narrowed the population
858/// it walked: the merges excluded as inert, and the consecutive-inconclusive
859/// run that stopped the walk early.
860///
861/// This exists so a corpus reader can tell a merge that was *observed and
862/// found clean* from one that was *never observed*, which a ledger of
863/// observations alone cannot express. Appends one versioned line per run, so
864/// an incremental re-run's restrictions accumulate rather than overwrite.
865/// Writes nothing when a run restricted nothing.
866///
867/// # Errors
868///
869/// The state directory cannot be created or the record cannot be appended.
870pub fn record_population_restrictions(
871 state_dir: &Path,
872 inert: &[String],
873 stopped_after_inconclusive: Option<usize>,
874) -> Result<(), String> {
875 if inert.is_empty() && stopped_after_inconclusive.is_none() {
876 return Ok(());
877 }
878 let record = serde_json::json!({
879 "format_version": 1,
880 "inert_merges": inert,
881 "inert_rule": "every path in the union of both parent diffs is documentation, licence, or forge bookkeeping",
882 "stopped_after_consecutive_inconclusive": stopped_after_inconclusive,
883 });
884 std::fs::create_dir_all(state_dir).map_err(|error| format!("create state dir: {error}"))?;
885 let mut line = serde_json::to_vec(&record)
886 .map_err(|error| format!("serialize population restrictions: {error}"))?;
887 line.push(b'\n');
888 std::fs::OpenOptions::new()
889 .create(true)
890 .append(true)
891 .open(state_dir.join("population.jsonl"))
892 .and_then(|mut file| std::io::Write::write_all(&mut file, &line))
893 .map_err(|error| format!("append population restrictions: {error}"))
894}
895
896/// Reproduction runs a harvest adds after a first `interaction_failure`
897/// verdict, so a specimen records six runs in total — the bar specimen #1
898/// (rust-lang/log `11eda98d`, reproduced 6/6) set for calling a flag a
899/// semantic conflict rather than a flake.
900pub const SPECIMEN_REPRODUCTION_RUNS: usize = 5;
901
902/// Packages a flagged merge and its reproduction runs as a corpus
903/// specimen under `state_dir/specimens/<merge>.json` (D27).
904///
905/// The specimen is metadata, not evidence: the observation rows it points
906/// at (by id) stay in the ledger, and the JSON records how often the
907/// interaction failure reproduced so a reader can tell a 6/6 conflict
908/// from a 1/6 flake without replaying anything. Nothing is published;
909/// the file lives in the operator's state directory.
910///
911/// # Errors
912///
913/// The merge cannot be resolved to two parents, or the specimen file
914/// cannot be written.
915pub fn write_specimen(
916 repo: &Path,
917 merge: &str,
918 outcomes: &[Result<DifferentialOutcome, String>],
919 state_dir: &Path,
920) -> Result<PathBuf, String> {
921 let (parent_a, parent_b, merged_revision) = resolve_merge_revisions(repo, merge)?;
922 let mut interaction_failures = 0u64;
923 let mut clean = 0u64;
924 let mut inconclusive = 0u64;
925 let mut errors = 0u64;
926 let mut observation_ids = Vec::new();
927 for outcome in outcomes {
928 match outcome {
929 Ok(outcome) => {
930 observation_ids.push(outcome.observation_id);
931 match outcome.verdict {
932 DifferentialVerdict::Clean => clean += 1,
933 DifferentialVerdict::InteractionFailure => interaction_failures += 1,
934 DifferentialVerdict::InconclusiveParentFailure => inconclusive += 1,
935 }
936 }
937 Err(_) => errors += 1,
938 }
939 }
940 let specimen = serde_json::json!({
941 "format_version": 1,
942 "merge": merged_revision,
943 "parent_a": parent_a,
944 "parent_b": parent_b,
945 "runs": outcomes.len(),
946 "interaction_failures": interaction_failures,
947 "clean": clean,
948 "inconclusive_parent_failures": inconclusive,
949 "run_errors": errors,
950 "observation_ids": observation_ids,
951 });
952 let dir = state_dir.join("specimens");
953 std::fs::create_dir_all(&dir).map_err(|error| format!("create specimens dir: {error}"))?;
954 let path = dir.join(format!("{merged_revision}.json"));
955 let body = serde_json::to_vec_pretty(&specimen)
956 .map_err(|error| format!("serialize specimen: {error}"))?;
957 std::fs::write(&path, body).map_err(|error| format!("write specimen: {error}"))?;
958 Ok(path)
959}
960
961/// Lands a green train: pushes `tip` to `branch` on the remote at
962/// `url` WITHOUT force, so git's fast-forward rule is the race guard —
963/// if the branch moved since the train was built, the push is rejected
964/// and the caller should rebuild on the next round.
965///
966/// # Errors
967///
968/// Push failures, including the non-fast-forward rejection.
969pub fn land(repo: &Path, url: &str, tip: &str, branch: &str) -> Result<(), String> {
970 git(
971 repo,
972 &["push", "-q", url, &format!("{tip}:refs/heads/{branch}")],
973 )
974 .map(|_| ())
975}
976
977/// Reverts a landed train (D23 auto-revert arm): reverts each of the
978/// train's merge commits (`base..tip`, first-parent, newest first) on
979/// top of `tip`, then pushes the result to `branch` WITHOUT force — if
980/// the branch moved past `tip` since landing, the push is rejected and
981/// a human decides. Returns the new branch tip. The reverted tree is
982/// byte-identical to `base`'s tree; history keeps the full record.
983///
984/// # Errors
985///
986/// Git failures, including a revert that itself conflicts (possible
987/// when later commits touched the same lines) and the non-fast-forward
988/// rejection — both leave the remote branch untouched.
989pub fn revert_train(
990 repo: &Path,
991 url: &str,
992 base: &str,
993 tip: &str,
994 branch: &str,
995) -> Result<String, String> {
996 let merges = git(
997 repo,
998 &[
999 "rev-list",
1000 "--first-parent",
1001 "--merges",
1002 &format!("{base}..{tip}"),
1003 ],
1004 )?;
1005 let merges: Vec<&str> = merges.split_whitespace().collect();
1006 if merges.is_empty() {
1007 return Err("no train merges between base and tip".to_string());
1008 }
1009 git(repo, &["checkout", "-q", "--detach", tip])?;
1010 for merge in &merges {
1011 // -m 1 = revert to the first parent (the train spine).
1012 if let Err(e) = git(repo, &["revert", "-m", "1", "--no-edit", merge]) {
1013 git(repo, &["revert", "--abort"]).ok();
1014 return Err(format!(
1015 "revert of {merge} conflicts, leaving branch alone: {e}"
1016 ));
1017 }
1018 }
1019 let new_tip = git(repo, &["rev-parse", "HEAD"])?.trim().to_string();
1020 git(
1021 repo,
1022 &["push", "-q", url, &format!("{new_tip}:refs/heads/{branch}")],
1023 )?;
1024 Ok(new_tip)
1025}
1026
1027/// One pull request's fate in a queued round.
1028///
1029/// The queue answers per change rather than per train, which is the
1030/// difference [`run_queue`] buys over [`build_train`]: a red build
1031/// blames the change that was red, and the changes ahead of it still
1032/// land in the same round.
1033#[derive(Debug, Clone, PartialEq, Eq)]
1034pub enum PrOutcome {
1035 /// Merged, tested green on its own speculative state, and part of
1036 /// [`QueuedRound::tip`].
1037 Landed,
1038 /// Merge conflict: evicted for its author to resolve, first-class,
1039 /// without blocking anything behind it (D6).
1040 Conflicted,
1041 /// CI failed on this change's own speculative state.
1042 Failed,
1043 /// Ejected because it declared a dependency on a change that
1044 /// failed. Not a verdict on this change.
1045 Ejected {
1046 /// The failing change it depends on.
1047 on: u64,
1048 },
1049 /// Already in by patch identity: the train rewrote or landed this
1050 /// edit earlier, so it was recognized rather than re-merged.
1051 AlreadyLanded,
1052 /// A merge strategy resolved beyond what the change proposed. Git's
1053 /// merge cannot produce this; it is here because the map from
1054 /// [`choir_queue::Rejection`] must be total, and a case dropped on
1055 /// the floor is a PR that gets no status at all.
1056 Unsafe,
1057 /// The round stopped before reaching this change. Nothing was
1058 /// decided about it and it is still waiting.
1059 Waiting,
1060}
1061
1062/// Result of one queued round.
1063#[derive(Debug, Clone, PartialEq, Eq)]
1064pub struct QueuedRound {
1065 /// The state the queue ended on: the commit to land, equal to the
1066 /// base when nothing landed.
1067 pub tip: String,
1068 /// Every pull request the round was given, in submission order.
1069 pub outcomes: Vec<(u64, PrOutcome)>,
1070 /// Why the round stopped early without blaming anybody: an
1071 /// executor that could not answer, or a merge we could not run.
1072 /// Distinct from every outcome above, because it is about us.
1073 pub stalled: Option<String>,
1074}
1075
1076/// Reads one change's outcome as the status its pull request gets.
1077///
1078/// Total by construction, and asserted as a correspondence rather than
1079/// as a table of literals: `green` exactly where the change is in, and
1080/// `failure` -- the state that asks an author to do something --
1081/// exactly where the change itself is what went wrong.
1082#[must_use]
1083pub fn pr_report(outcome: &PrOutcome) -> TrainReport {
1084 match outcome {
1085 PrOutcome::Landed => TrainReport {
1086 green: true,
1087 state: "success",
1088 description: "merged and green on the speculative train",
1089 },
1090 PrOutcome::AlreadyLanded => TrainReport {
1091 green: true,
1092 state: "success",
1093 description: "already landed (patch identity)",
1094 },
1095 PrOutcome::Conflicted => TrainReport {
1096 green: false,
1097 state: "failure",
1098 description: "merge conflict; resolve and resubmit",
1099 },
1100 PrOutcome::Failed => TrainReport {
1101 green: false,
1102 state: "failure",
1103 description: "train CI failed on this change",
1104 },
1105 PrOutcome::Unsafe => TrainReport {
1106 green: false,
1107 state: "failure",
1108 description: "merge resolution edited beyond the change",
1109 },
1110 PrOutcome::Ejected { .. } => TrainReport {
1111 green: false,
1112 state: "error",
1113 description: "ejected: a change it depends on failed",
1114 },
1115 PrOutcome::Waiting => TrainReport {
1116 green: false,
1117 state: "error",
1118 description: "queue stalled before reaching this change",
1119 },
1120 }
1121}
1122
1123/// Runs one speculative round through the D5 merge queue.
1124///
1125/// This is [`build_train`]'s successor and differs from it in what a
1126/// verdict is about. `build_train` merges everything and asks CI once,
1127/// so one red change makes every PR in the train red. The queue tests
1128/// each change against the state produced by everything ahead of it,
1129/// which is the "exactly as if they had been tested one at a time"
1130/// property D5 exists for: the green prefix lands, the failure is
1131/// blamed on the change that failed, and the window narrows.
1132///
1133/// Nothing speculative leaves the machine and no ref is moved here. The
1134/// caller decides whether to land [`QueuedRound::tip`].
1135///
1136/// # Errors
1137///
1138/// The base or a pull request head does not resolve, or the executor
1139/// speaks a protocol this build does not.
1140pub fn run_queue(
1141 repo: &Path,
1142 base: &str,
1143 prs: &[(u64, String)],
1144 spec: &CommandSpec,
1145 ci: &mut dyn CiExecutor,
1146) -> Result<QueuedRound, String> {
1147 let info = ci.info().map_err(|error| error.to_string())?;
1148 if info.protocol != PROTOCOL {
1149 return Err(format!(
1150 "executor `{}` speaks protocol {} and this build speaks {PROTOCOL}",
1151 info.name, info.protocol
1152 ));
1153 }
1154
1155 let speculator = choir_queue::git::GitSpeculator::new(repo.to_path_buf());
1156 let base_oid = speculator.verify(base)?;
1157 let mut queue = choir_queue::MergeQueue::with_speculator(
1158 &base_oid,
1159 Box::new(choir_queue::git::GitSpeculator::new(repo.to_path_buf())),
1160 );
1161
1162 let mut command = Vec::with_capacity(spec.args.len() + 1);
1163 command.push(spec.program.clone());
1164 command.extend(spec.args.iter().cloned());
1165 queue.set_job_template(choir_queue::JobTemplate {
1166 command,
1167 environment: effective_environment(&spec.env),
1168 deadline: spec.timeout_seconds.map(Duration::from_secs),
1169 // The train is speculative by definition: a shared cache
1170 // written from a state nobody approved is the CREEP shape, and
1171 // the queue is exactly where an attacker would reach it.
1172 may_write_cache: false,
1173 });
1174
1175 for (id, head) in prs {
1176 let oid = speculator.verify(head)?;
1177 queue.submit(choir_queue::Change {
1178 id: *id,
1179 workspace: format!("pr/{id}"),
1180 base: base_oid.clone(),
1181 proposed: oid,
1182 depends: Vec::new(),
1183 });
1184 }
1185
1186 // In memory, because the forge is canonical here and choir mirrors
1187 // it (D21): the landings this records are the queue's own ordering
1188 // of a round, not a claim about the repository, and nothing outside
1189 // this call reads them.
1190 let report = queue.drain_in_memory(ci);
1191
1192 let mut outcomes: Vec<(u64, PrOutcome)> = Vec::with_capacity(prs.len());
1193 for (id, _) in prs {
1194 let outcome = if report.merged.contains(id) {
1195 PrOutcome::Landed
1196 } else if let Some((_, why)) = report.rejected.iter().find(|(r, _)| r == id) {
1197 match why {
1198 choir_queue::Rejection::Conflict => PrOutcome::Conflicted,
1199 choir_queue::Rejection::CiFailure => PrOutcome::Failed,
1200 choir_queue::Rejection::SafetyViolation { .. } => PrOutcome::Unsafe,
1201 choir_queue::Rejection::DependencyEjection { on } => PrOutcome::Ejected { on: *on },
1202 choir_queue::Rejection::AlreadyLanded => PrOutcome::AlreadyLanded,
1203 }
1204 } else {
1205 PrOutcome::Waiting
1206 };
1207 outcomes.push((*id, outcome));
1208 }
1209
1210 Ok(QueuedRound {
1211 tip: report.final_state,
1212 outcomes,
1213 stalled: report.provider_error,
1214 })
1215}