Skip to main content

ostraka_runtime/
gate.rs

1//! The gate, and the one type the orchestrator cannot forge.
2//!
3//! The structural rule is that whoever wrote a change cannot be the one who
4//! approves it. Stating that in documentation makes it a convention; stating it
5//! here makes it a fact about the program.
6//!
7//! [`MergeToken`] has private fields and no public constructor. The only way to
8//! obtain one is [`evaluate`], which requires both an [`AllChecksPassed`] —
9//! itself obtainable only by actually running the checks — and an approval whose
10//! reviewer differs from the change's author. An orchestrator holding every
11//! other type in this crate still cannot produce one.
12//!
13//! This is also why the gate is not a separate crate. Across a crate boundary it
14//! would have to be an injectable trait, and an injectable gate is a bypassable
15//! one — which is the failure the whole design exists to prevent.
16
17use ostraka_core::gate::{Approval, Check, CheckRecord, GateSpec};
18use ostraka_core::identity::ActorId;
19use std::io::Read;
20use std::path::Path;
21use std::process::{Command, Stdio};
22use std::time::{Duration, Instant};
23
24/// Proof that every required check ran and passed.
25///
26/// Constructible only by [`run_checks`], in this module. It carries no data a
27/// caller could fabricate from elsewhere.
28#[derive(Debug)]
29pub struct AllChecksPassed {
30    records: Vec<CheckRecord>,
31}
32
33impl AllChecksPassed {
34    pub fn records(&self) -> &[CheckRecord] {
35        &self.records
36    }
37}
38
39/// Permission to merge a change. Cannot be constructed outside this module.
40#[derive(Debug)]
41pub struct MergeToken {
42    author: ActorId,
43    reviewer: ActorId,
44    checks: Vec<CheckRecord>,
45}
46
47impl MergeToken {
48    pub fn author(&self) -> &ActorId {
49        &self.author
50    }
51
52    pub fn reviewer(&self) -> &ActorId {
53        &self.reviewer
54    }
55
56    pub fn checks(&self) -> &[CheckRecord] {
57        &self.checks
58    }
59}
60
61/// Why a gate refused.
62///
63/// `ChecksFailed` carries the check records rather than only the names. A run
64/// that fails is exactly when the evidence matters most, so the refusal itself
65/// holds it and a caller cannot record the outcome without it.
66#[derive(Debug, PartialEq, Eq)]
67pub enum Refusal {
68    /// One or more required checks failed. Named so the reason is legible.
69    ChecksFailed {
70        failed: Vec<String>,
71        records: Vec<CheckRecord>,
72    },
73    /// The reviewer rejected the change.
74    Rejected { reason: String },
75    /// The authoring agent could not run, and left nothing behind.
76    ///
77    /// Distinct from a rejection: nothing was judged. A vendor that hit a rate
78    /// limit or an expired credential and a vendor that considered the task and
79    /// declined it are the same empty diff, and only one of them is about the
80    /// change. Its own words are carried so the difference is legible.
81    AuthorFailed {
82        code: String,
83        diagnostics: Option<String>,
84    },
85    /// The change touched paths the project's policy does not allow.
86    PolicyViolation { reason: String },
87    /// The worktree could not be made ready to work in.
88    ///
89    /// Nothing was judged and nothing was written: the environment was not
90    /// there. Reporting it as a failed check would say the change was rejected,
91    /// which is the confusion this variant exists to end.
92    SetupFailed { step: String, reason: String },
93    /// The operator stopped the run.
94    ///
95    /// Not a judgement on the change at all — nobody finished looking at it.
96    Interrupted,
97    /// The authoring agent outlived the ceiling and was killed.
98    ///
99    /// Not a rejection and not a crash: nothing was judged, and the change was
100    /// never finished. Reporting it as either would blame the work for the
101    /// clock.
102    TimedOut { after_secs: u64 },
103    /// The authoring agent ran cleanly and changed nothing.
104    ///
105    /// A legitimate answer to a task — the work was already done, or the agent
106    /// judged that nothing was needed — and not one a reviewer can rule on: it
107    /// would be handed an empty diff and asked what it thinks of it.
108    NoChange,
109    /// The reviewer and the author are the same identity.
110    SelfApproval { actor: ActorId },
111}
112
113/// Runs the project's checks in a worktree.
114///
115/// Returns the proof type only when every required check exited zero. Optional
116/// checks are recorded but do not block, so that a project can observe a check
117/// before it enforces one.
118pub fn run_checks(
119    spec: &GateSpec,
120    worktree: &Path,
121    finished: &mut dyn FnMut(&CheckRecord),
122) -> std::result::Result<AllChecksPassed, Refusal> {
123    run_checks_until(
124        spec,
125        worktree,
126        &ostraka_adapter::interrupt::Stop::new(),
127        finished,
128    )
129}
130
131/// [`run_checks`], answering to one run's stop as well as to Ctrl-C.
132///
133/// The gate is usually the longest part of a run, so a run that can be stopped
134/// everywhere except its checks cannot really be stopped.
135pub fn run_checks_until(
136    spec: &GateSpec,
137    worktree: &Path,
138    stop: &ostraka_adapter::interrupt::Stop,
139    finished: &mut dyn FnMut(&CheckRecord),
140) -> std::result::Result<AllChecksPassed, Refusal> {
141    let mut records = Vec::with_capacity(spec.checks.len());
142    let mut failed = Vec::new();
143
144    let ceiling = spec.timeout_secs.map(Duration::from_secs);
145    for check in &spec.checks {
146        let record = run_one(check, worktree, ceiling, stop);
147        // Reported one at a time, as each finishes. The gate is usually the
148        // longest part of a run — four cargo checks on this repository take
149        // twelve seconds — and a caller that only learns the outcome at the end
150        // has nothing to show for those twelve seconds but a still screen.
151        finished(&record);
152        if check.required && !record.passed() {
153            failed.push(check.name.clone());
154        }
155        records.push(record);
156    }
157
158    if failed.is_empty() {
159        Ok(AllChecksPassed { records })
160    } else {
161        Err(Refusal::ChecksFailed { failed, records })
162    }
163}
164
165fn run_one(
166    check: &Check,
167    worktree: &Path,
168    ceiling: Option<Duration>,
169    stop: &ostraka_adapter::interrupt::Stop,
170) -> CheckRecord {
171    let mut record = run_command_until(&check.cmd, worktree, ceiling, stop);
172    record.name = check.name.clone();
173    record
174}
175
176/// Runs one shell command in a worktree, bounded, capturing both streams.
177///
178/// Shared with worktree preparation, which needs exactly this and must not be
179/// reported as a gate check.
180pub fn run_command(cmd: &str, worktree: &Path, ceiling: Option<Duration>) -> CheckRecord {
181    run_command_until(
182        cmd,
183        worktree,
184        ceiling,
185        &ostraka_adapter::interrupt::Stop::new(),
186    )
187}
188
189/// [`run_command`], answering to one run's stop as well as to Ctrl-C.
190pub fn run_command_until(
191    cmd: &str,
192    worktree: &Path,
193    ceiling: Option<Duration>,
194    until: &ostraka_adapter::interrupt::Stop,
195) -> CheckRecord {
196    let started = Instant::now();
197    let check = Check {
198        name: String::new(),
199        cmd: cmd.to_string(),
200        required: true,
201    };
202
203    let mut command = Command::new("sh");
204    // A check that spawns helpers — a test runner forking workers, a build
205    // starting a server — leaves them behind when only the shell is killed.
206    #[cfg(unix)]
207    {
208        use std::os::unix::process::CommandExt;
209        command.process_group(0);
210    }
211    let spawned = command
212        .arg("-c")
213        .arg(&check.cmd)
214        .current_dir(worktree)
215        .stdin(Stdio::null())
216        .stdout(Stdio::piped())
217        .stderr(Stdio::piped())
218        .spawn();
219
220    let (exit_code, stdout, stderr) = match spawned {
221        Ok(child) => wait_for(child, ceiling, until),
222        // A check that could not be started has not passed. Recording the
223        // failure is the point; swallowing it would be the bug.
224        Err(e) => (None, String::new(), e.to_string()),
225    };
226
227    CheckRecord {
228        name: check.name.clone(),
229        cmd: check.cmd.clone(),
230        exit_code,
231        stdout,
232        stderr,
233        duration_ms: started.elapsed().as_millis() as u64,
234    }
235}
236
237/// Stops a check and everything it started.
238///
239/// The group's id is the shell's own pid, set by `process_group(0)` above.
240/// `libc` rather than a shelled-out `kill`: procps reads `-1234` as a pid, not
241/// a group, and signals the shell alone while reporting success — which leaves
242/// the workers behind that this is here to collect.
243fn stop(child: &mut std::process::Child) {
244    #[cfg(unix)]
245    // SAFETY: a signal call with no memory contract. A group that has already
246    // exited answers `ESRCH`, which is the outcome being asked for.
247    unsafe {
248        libc::killpg(child.id() as libc::pid_t, libc::SIGTERM);
249    }
250    // Always, and last: a check ignoring TERM still has to stop.
251    let _ = child.kill();
252}
253
254/// Runs a check to completion, or kills it for outlasting the ceiling.
255///
256/// Both streams are read on their own threads: a check that fills a pipe buffer
257/// while this thread waits on the other one deadlocks, and a build that prints
258/// a lot is the ordinary case rather than the exotic one.
259fn wait_for(
260    mut child: std::process::Child,
261    ceiling: Option<Duration>,
262    until: &ostraka_adapter::interrupt::Stop,
263) -> (Option<i32>, String, String) {
264    fn reader(stream: Option<impl Read + Send + 'static>) -> std::sync::mpsc::Receiver<String> {
265        let (tx, rx) = std::sync::mpsc::channel();
266        std::thread::spawn(move || {
267            let mut text = String::new();
268            if let Some(mut stream) = stream {
269                let _ = stream.read_to_string(&mut text);
270            }
271            let _ = tx.send(text);
272        });
273        rx
274    }
275
276    let out = reader(child.stdout.take());
277    let err = reader(child.stderr.take());
278
279    // Polled whether or not there is a ceiling. With none, this used to wait on
280    // the child outright — so a check with no `timeout_secs` could not be
281    // stopped by anything, Ctrl-C included, and a run was only as stoppable as
282    // its slowest unbounded check.
283    let deadline = ceiling.map(|ceiling| Instant::now() + ceiling);
284    // `Some(true)` for the ceiling, `Some(false)` for a stop, `None` for a check
285    // that ended by itself — kept apart because the record says which.
286    let killed_by = loop {
287        match child.try_wait() {
288            Ok(Some(_)) => break None,
289            Err(_) => break None,
290            Ok(None) => {}
291        }
292        let expired = deadline.is_some_and(|deadline| Instant::now() >= deadline);
293        if expired || until.requested() {
294            stop(&mut child);
295            break Some(expired);
296        }
297        std::thread::sleep(Duration::from_millis(25));
298    };
299    let killed = killed_by.is_some();
300
301    let status = child.wait().ok();
302    // After a kill, take what already arrived rather than waiting for the
303    // streams to close: killing a shell does not kill what it spawned, and a
304    // surviving grandchild holds these pipes open. Waiting for EOF here would
305    // wait on exactly the process the ceiling just stopped waiting for.
306    let collect = |rx: std::sync::mpsc::Receiver<String>| -> String {
307        if killed {
308            rx.recv_timeout(Duration::from_millis(200))
309                .unwrap_or_default()
310        } else {
311            rx.recv().unwrap_or_default()
312        }
313    };
314    let stdout = collect(out);
315    let mut stderr = collect(err);
316    if killed {
317        // Said in the record rather than left as a bare signal death, which
318        // reads like the check crashed on its own.
319        stderr.push_str(&match killed_by {
320            // Asked to stop — by this run's own stop or by Ctrl-C — which says
321            // nothing about how long the check takes. Naming the ceiling here
322            // would record a timeout that did not happen, as "0s" when there
323            // was no ceiling at all. Raised in review.
324            Some(false) => {
325                "\nostraka: stopped — the run was asked to stop before this check finished\n"
326                    .to_string()
327            }
328            _ => format!(
329                "\nostraka: killed after {}s — the gate's timeout_secs\n",
330                ceiling.map(|c| c.as_secs()).unwrap_or_default()
331            ),
332        });
333    }
334    // A killed process reports no code, which is already how "did not pass" is
335    // spelled everywhere else here.
336    (status.and_then(|s| s.code()), stdout, stderr)
337}
338
339/// The gate itself: the only path to a [`MergeToken`].
340pub fn evaluate(
341    checks: AllChecksPassed,
342    author: &ActorId,
343    approval: &Approval,
344    must_differ_from_author: bool,
345) -> std::result::Result<MergeToken, Refusal> {
346    if must_differ_from_author && &approval.reviewer == author {
347        return Err(Refusal::SelfApproval {
348            actor: author.clone(),
349        });
350    }
351
352    match &approval.verdict {
353        ostraka_core::gate::Verdict::Reject { reason } => Err(Refusal::Rejected {
354            reason: reason.clone(),
355        }),
356        ostraka_core::gate::Verdict::Approve => Ok(MergeToken {
357            author: author.clone(),
358            reviewer: approval.reviewer.clone(),
359            checks: checks.records,
360        }),
361    }
362}
363
364/// The gate again, from a finished run's own evidence.
365///
366/// A run mints its token in memory and the token dies with the process, so
367/// promoting an approved change later has to ask the same question a second
368/// time. It is asked here, in the module that owns the answer: nothing outside
369/// gains a way to build a [`MergeToken`], and a run that was refused cannot be
370/// promoted by a caller that decides it disagrees.
371///
372/// The evidence is the run record, which is not inside the worktree. An agent
373/// works in a worktree; the records live above it, so a fleet cannot write its
374/// own approval. A person with a text editor can — and that same person can
375/// commit anything they like directly. This gate is between the fleet and the
376/// branch, not between the operator and their own repository.
377///
378/// Required checks are read from the project's current spec, not from the
379/// record: a check added since the run was made has never passed, and a record
380/// that predates it must not promote as though it had.
381pub fn reaffirm(
382    spec: &GateSpec,
383    record: &ostraka_core::record::RunRecord,
384    must_differ_from_author: bool,
385) -> std::result::Result<MergeToken, Refusal> {
386    let mut records = Vec::with_capacity(spec.checks.len());
387    let mut failed = Vec::new();
388
389    for check in &spec.checks {
390        match record.checks.iter().find(|c| c.name == check.name) {
391            Some(evidence) => {
392                if check.required && !evidence.passed() {
393                    failed.push(check.name.clone());
394                }
395                records.push(evidence.clone());
396            }
397            None if check.required => failed.push(check.name.clone()),
398            None => {}
399        }
400    }
401
402    if !failed.is_empty() {
403        return Err(Refusal::ChecksFailed { failed, records });
404    }
405
406    let Some(approval) = &record.approval else {
407        return Err(Refusal::Rejected {
408            reason: "the run record carries no approval, so nothing reviewed this change"
409                .to_string(),
410        });
411    };
412
413    evaluate(
414        AllChecksPassed { records },
415        &record.author,
416        approval,
417        must_differ_from_author,
418    )
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn a_check_with_no_ceiling_can_still_be_stopped() {
427        // Unbounded checks used to be unstoppable: with no deadline there was
428        // no loop, only a wait on the child.
429        let stop = ostraka_adapter::interrupt::Stop::new();
430        let started = Instant::now();
431        let record = std::thread::scope(|scope| {
432            scope.spawn(|| {
433                std::thread::sleep(Duration::from_millis(300));
434                stop.request();
435            });
436            run_command_until("sleep 120", Path::new("."), None, &stop)
437        });
438        assert!(
439            started.elapsed() < Duration::from_secs(20),
440            "an unbounded check ignored its stop for {:?}",
441            started.elapsed()
442        );
443        assert!(!record.passed(), "a stopped check passed");
444        // And the record says it was stopped, not that it ran out of time.
445        assert!(record.stderr.contains("asked to stop"), "{}", record.stderr);
446        assert!(
447            !record.stderr.contains("timeout_secs"),
448            "a stop was recorded as a timeout: {}",
449            record.stderr
450        );
451    }
452    use ostraka_core::gate::{ReviewPolicy, Verdict};
453
454    fn spec(cmds: &[(&str, &str, bool)]) -> GateSpec {
455        GateSpec {
456            timeout_secs: None,
457            checks: cmds
458                .iter()
459                .map(|(name, cmd, required)| Check {
460                    name: (*name).to_string(),
461                    cmd: (*cmd).to_string(),
462                    required: *required,
463                })
464                .collect(),
465            review: ReviewPolicy::default(),
466        }
467    }
468
469    #[test]
470    fn a_check_that_hangs_is_killed_and_says_so() {
471        // A wedged test suite otherwise wedges the gate, and the gate is what
472        // every run waits on.
473        let mut spec = spec(&[("hang", "sleep 120", true)]);
474        spec.timeout_secs = Some(1);
475        let started = Instant::now();
476        let refusal = run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
477        assert!(
478            started.elapsed() < Duration::from_secs(20),
479            "the ceiling was not enforced"
480        );
481        match refusal {
482            Refusal::ChecksFailed { failed, records } => {
483                assert_eq!(failed, ["hang"]);
484                assert!(
485                    records[0].stderr.contains("killed after"),
486                    "a killed check read as a crash: {:?}",
487                    records[0].stderr
488                );
489            }
490            other => panic!("wrong refusal: {other:?}"),
491        }
492    }
493
494    #[test]
495    fn the_workers_a_hung_check_started_are_stopped_with_it() {
496        // Killing the shell does not kill what it forked. A test runner's
497        // workers and a build's servers outlive the check that started them,
498        // and go on holding the ports and the CPU the ceiling was meant to
499        // release.
500        let marker = std::env::temp_dir().join(format!("ostraka-gate-pg-{}", std::process::id()));
501        let _ = std::fs::remove_file(&marker);
502        let mut spec = spec(&[(
503            "forks",
504            &format!("(sleep 4; touch {}) & sleep 120", marker.display()),
505            true,
506        )]);
507        spec.timeout_secs = Some(1);
508        run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
509
510        std::thread::sleep(Duration::from_secs(6));
511        assert!(
512            !marker.exists(),
513            "a worker outlived the check that started it"
514        );
515        let _ = std::fs::remove_file(&marker);
516    }
517
518    #[test]
519    fn a_check_that_finishes_inside_the_ceiling_is_untouched() {
520        let mut spec = spec(&[("quick", "echo fine", true)]);
521        spec.timeout_secs = Some(30);
522        let passed = run_checks(&spec, Path::new("."), &mut ignore).expect("passes");
523        assert!(passed.records()[0].passed());
524        assert!(!passed.records()[0].stderr.contains("killed"));
525    }
526
527    #[test]
528    fn checks_actually_run_and_their_output_is_captured() {
529        let passed = run_checks(
530            &spec(&[("echo", "echo hello", true)]),
531            Path::new("."),
532            &mut ignore,
533        )
534        .expect("check passes");
535        let record = &passed.records()[0];
536        assert!(record.passed());
537        assert!(record.stdout.contains("hello"));
538    }
539
540    #[test]
541    fn a_failing_required_check_refuses_the_gate() {
542        let refusal = run_checks(
543            &spec(&[("fail", "exit 1", true)]),
544            Path::new("."),
545            &mut ignore,
546        )
547        .expect_err("must refuse");
548        match refusal {
549            Refusal::ChecksFailed { failed, records } => {
550                assert_eq!(failed, ["fail"]);
551                // The evidence travels with the refusal.
552                assert_eq!(records.len(), 1);
553                assert_eq!(records[0].exit_code, Some(1));
554            }
555            other => panic!("wrong refusal: {other:?}"),
556        }
557    }
558
559    #[test]
560    fn an_optional_check_is_recorded_but_does_not_block() {
561        let passed = run_checks(
562            &spec(&[("advisory", "exit 3", false), ("real", "true", true)]),
563            Path::new("."),
564            &mut ignore,
565        )
566        .expect("optional failure does not block");
567        assert_eq!(passed.records().len(), 2);
568        assert!(!passed.records()[0].passed());
569    }
570
571    #[test]
572    fn a_command_that_cannot_run_is_a_failure_not_a_pass() {
573        let refusal = run_checks(
574            &spec(&[("missing", "definitely-not-a-real-binary-xyz", true)]),
575            Path::new("."),
576            &mut ignore,
577        )
578        .expect_err("must refuse");
579        assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
580    }
581
582    /// For the tests that are not about who is watching.
583    fn ignore(_: &CheckRecord) {}
584
585    #[test]
586    fn each_check_is_reported_as_it_finishes_rather_than_all_at_the_end() {
587        // The gate is the longest part of a run. A caller that learns the
588        // outcome only at the end has nothing to show for that time.
589        let mut seen: Vec<String> = Vec::new();
590        let refusal = run_checks(
591            &spec(&[
592                ("first", "true", true),
593                ("second", "exit 1", true),
594                ("third", "true", true),
595            ]),
596            Path::new("."),
597            &mut |record| seen.push(format!("{} {}", record.name, record.passed())),
598        )
599        .expect_err("the second check fails");
600
601        assert_eq!(seen, ["first true", "second false", "third true"]);
602        // Reported even for the run that is about to be refused: the failing
603        // check's output is the thing someone is waiting to read.
604        assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
605    }
606
607    fn passing_checks() -> AllChecksPassed {
608        run_checks(&spec(&[("ok", "true", true)]), Path::new("."), &mut ignore).expect("passes")
609    }
610
611    #[test]
612    fn the_author_cannot_approve_their_own_change() {
613        let archon = ActorId::new("archon");
614        let refusal = evaluate(
615            passing_checks(),
616            &archon,
617            &Approval {
618                reviewer: archon.clone(),
619                verdict: Verdict::Approve,
620            },
621            true,
622        )
623        .expect_err("self-approval must be refused");
624        assert_eq!(refusal, Refusal::SelfApproval { actor: archon });
625    }
626
627    #[test]
628    fn an_independent_approval_mints_a_token() {
629        let token = evaluate(
630            passing_checks(),
631            &ActorId::new("archon"),
632            &Approval {
633                reviewer: ActorId::new("ephor"),
634                verdict: Verdict::Approve,
635            },
636            true,
637        )
638        .expect("independent approval");
639        assert_eq!(token.author().as_str(), "archon");
640        assert_eq!(token.reviewer().as_str(), "ephor");
641        assert_eq!(token.checks().len(), 1);
642    }
643
644    #[test]
645    fn a_rejection_mints_nothing() {
646        let refusal = evaluate(
647            passing_checks(),
648            &ActorId::new("archon"),
649            &Approval {
650                reviewer: ActorId::new("ephor"),
651                verdict: Verdict::Reject {
652                    reason: "no tests".into(),
653                },
654            },
655            true,
656        )
657        .expect_err("rejection");
658        assert_eq!(
659            refusal,
660            Refusal::Rejected {
661                reason: "no tests".to_string()
662            }
663        );
664    }
665}