Skip to main content

ostraka_runtime/
route.rs

1//! Choosing who writes and who reviews.
2//!
3//! Routing is on capability and independence, never on habit. The one rule that
4//! is not negotiable: the reviewer must not be the author. The gate enforces
5//! that again on identities; enforcing it here as well means a run fails before
6//! any work is done rather than after.
7
8use crate::{Error, Result};
9use ostraka_adapter::{Availability, Profile, VendorAdapter, process::ProcessAdapter};
10use std::path::Path;
11
12/// The pair of adapters a run will use.
13pub struct Routing {
14    pub author: Box<dyn VendorAdapter>,
15    pub reviewer: Box<dyn VendorAdapter>,
16}
17
18// Trait objects are not Debug, but the only thing worth printing about a
19// routing decision is who was picked for what.
20impl std::fmt::Debug for Routing {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.debug_struct("Routing")
23            .field("author", &self.author.id())
24            .field("reviewer", &self.reviewer.id())
25            .finish()
26    }
27}
28
29/// Picks an author and a reviewer from the available profiles.
30///
31/// Explicit ids are honoured as given: naming an adapter is a decision, and a
32/// decision that turns out to be unrunnable should fail by name rather than be
33/// quietly substituted. Everything chosen automatically is drawn only from the
34/// profiles that can actually run here, in id order, so the choice is
35/// reproducible rather than dependent on directory listing order.
36///
37/// `timeout` is the ceiling either side gets before it is killed, bound here
38/// for the same reason as the rest: what a run is allowed to do is decided when
39/// the routing is, not by whatever calls `launch` later.
40///
41/// `isolation_root` is where a profile that relocates its vendor's home
42/// directory may put it. It is bound here, alongside the read-only review
43/// invocation, for the same reason: what a run is allowed to read is decided
44/// when the routing is, not by whatever calls `launch` later.
45pub fn select(
46    profiles: &[Profile],
47    author_id: Option<&str>,
48    reviewer_id: Option<&str>,
49    isolation_root: &Path,
50    timeout: Option<std::time::Duration>,
51) -> Result<Routing> {
52    select_until(
53        profiles,
54        author_id,
55        reviewer_id,
56        isolation_root,
57        timeout,
58        &ostraka_adapter::interrupt::Stop::new(),
59    )
60}
61
62/// [`select`], with both adapters answering to one run's stop.
63///
64/// Pass the same stop to [`crate::orchestrator::run_task_until`], which uses it
65/// for the gate: the adapters get theirs here because this is where they are
66/// built, and a run whose author stopped but whose checks did not has not
67/// stopped.
68pub fn select_until(
69    profiles: &[Profile],
70    author_id: Option<&str>,
71    reviewer_id: Option<&str>,
72    isolation_root: &Path,
73    timeout: Option<std::time::Duration>,
74    stop: &ostraka_adapter::interrupt::Stop,
75) -> Result<Routing> {
76    if profiles.is_empty() {
77        return Err(Error::Other("no adapter profiles found".to_string()));
78    }
79
80    let find = |id: &str| -> Result<Profile> {
81        profiles
82            .iter()
83            .find(|p| p.id == id)
84            .cloned()
85            .ok_or_else(|| Error::Other(format!("no adapter profile with id {id:?}")))
86    };
87
88    let (author, reviewer) = match (author_id, reviewer_id) {
89        (Some(a), Some(r)) => (find(a)?, find(r)?),
90        (Some(a), None) => {
91            let author = find(a)?;
92            let reviewer = first_other(profiles, Some(&author), Side::Review)?;
93            (author, reviewer)
94        }
95        (None, Some(r)) => {
96            let reviewer = find(r)?;
97            let author = first_other(profiles, Some(&reviewer), Side::Author)?;
98            (author, reviewer)
99        }
100        (None, None) => {
101            let author = first_other(profiles, None, Side::Author)?;
102            let reviewer = first_other(profiles, Some(&author), Side::Review)?;
103            (author, reviewer)
104        }
105    };
106
107    if author.id == reviewer.id {
108        return Err(Error::Other(format!(
109            "author and reviewer are the same adapter ({:?}); a change cannot review itself",
110            author.id
111        )));
112    }
113
114    Ok(Routing {
115        author: Box::new(
116            ProcessAdapter::new(author)
117                .isolated_under(isolation_root)
118                .within(timeout)
119                .stopped_by(stop.clone()),
120        ),
121        reviewer: Box::new(
122            ProcessAdapter::reviewing(reviewer)
123                .isolated_under(isolation_root)
124                .within(timeout)
125                .stopped_by(stop.clone()),
126        ),
127    })
128}
129
130/// Which side of a run an automatic choice is being made for.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum Side {
133    Author,
134    Review,
135}
136
137/// The profile to pair with another one, or the first to pick when there is no
138/// other one yet.
139///
140/// Availability is asked, not assumed. A profile whose CLI is absent or refuses
141/// to answer would otherwise be routed to and fail after a worktree had been
142/// created and a task dispatched.
143///
144/// Among the usable ones, a profile invoking a *different binary* is preferred.
145/// Independence is expressed between profiles, so two profiles of one vendor on
146/// two models are a legitimate pair — but they share a lineage, a system prompt
147/// and a set of blind spots, and picking them over an actually different vendor
148/// because their ids happen to sort first would weaken every unattended run.
149/// Naming one explicitly still gets it: this orders a choice nobody made.
150///
151/// When the choice is a reviewer, one thing outranks even that: whether the
152/// profile has a review invocation at all. A profile that declares no
153/// `review_args` reviews with its author invocation, which can write — and a
154/// reviewer that can write can alter the change it is judging. Two shipped
155/// profiles are in that position, and routing used to pick one as a reviewer
156/// whenever its id sorted first. Posture comes before binary because one is a
157/// question of whether the verdict can be trusted and the other of how good it
158/// is. It is still an ordering, not a refusal: a workspace whose only other
159/// profile has no review invocation gets that profile, and the run refuses on
160/// its own if the worktree changes under review.
161fn first_other(profiles: &[Profile], exclude: Option<&Profile>, side: Side) -> Result<Profile> {
162    let excluded_id = exclude.map(|p| p.id.as_str()).unwrap_or_default();
163    let excluded_command = exclude.map(|p| p.command.as_str());
164    let mut others: Vec<&Profile> = profiles.iter().filter(|p| p.id != excluded_id).collect();
165    others.sort_by(|a, b| {
166        // Named for what is compared, not for what it implies. A profile with
167        // no review invocation reviews with its author one, which is usually
168        // the one that can write — usually, not by definition, so the name says
169        // the fact and the doc comment above says why it matters.
170        let reviews_with_author_invocation =
171            |p: &Profile| side == Side::Review && p.review_args.is_none();
172        let same_binary_as_other = |p: &Profile| excluded_command == Some(p.command.as_str());
173        reviews_with_author_invocation(a)
174            .cmp(&reviews_with_author_invocation(b))
175            .then_with(|| same_binary_as_other(a).cmp(&same_binary_as_other(b)))
176            .then_with(|| a.id.cmp(&b.id))
177    });
178
179    let mut unusable: Vec<String> = Vec::new();
180    for candidate in &others {
181        match ProcessAdapter::new((*candidate).clone()).probe() {
182            a if a.is_ready() => return Ok((*candidate).clone()),
183            Availability::NotFound { command } => {
184                unusable.push(format!("{}: {command} not on PATH", candidate.id));
185            }
186            Availability::Unusable { reason } => {
187                unusable.push(format!("{}: {reason}", candidate.id));
188            }
189            // `is_ready` covered this arm; kept exhaustive rather than
190            // unreachable so a new variant is a compile error, not a silent pass.
191            Availability::Ready { .. } => return Ok((*candidate).clone()),
192        }
193    }
194
195    if others.is_empty() {
196        return Err(Error::Other(
197            "only one adapter profile is configured, so no independent reviewer exists; \
198             add a second profile in adapters/"
199                .to_string(),
200        ));
201    }
202    let besides = if excluded_id.is_empty() {
203        String::new()
204    } else {
205        format!(" besides {excluded_id:?}")
206    };
207    Err(Error::Other(format!(
208        "no usable adapter profile{besides}; run `ostraka adapters` for detail. Checked — {}",
209        unusable.join("; ")
210    )))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    /// Routing never launches anything, so any path will do here.
218    fn root() -> &'static Path {
219        Path::new("/nonexistent-isolation-root")
220    }
221
222    fn command_profile(id: &str, command: &str) -> Profile {
223        Profile::parse(&format!(
224            r#"
225            id = "{id}"
226            command = "{command}"
227            args = ["{{{{prompt}}}}"]
228            "#
229        ))
230        .expect("valid")
231    }
232
233    fn profile(id: &str) -> Profile {
234        Profile::parse(&format!(
235            r#"
236            id = "{id}"
237            command = "true"
238            args = ["{{{{prompt}}}}"]
239            "#
240        ))
241        .expect("valid")
242    }
243
244    #[test]
245    fn a_lone_adapter_cannot_review_itself() {
246        let err = select(&[profile("solo")], None, None, root(), None).expect_err("must refuse");
247        assert!(err.to_string().contains("no independent reviewer"));
248    }
249
250    #[test]
251    fn naming_the_same_adapter_twice_is_refused() {
252        let profiles = [profile("a"), profile("b")];
253        let err = select(&profiles, Some("a"), Some("a"), root(), None).expect_err("must refuse");
254        assert!(err.to_string().contains("cannot review itself"));
255    }
256
257    #[test]
258    fn selection_is_deterministic_not_listing_order() {
259        let forward =
260            select(&[profile("b"), profile("a")], None, None, root(), None).expect("routes");
261        let reverse =
262            select(&[profile("a"), profile("b")], None, None, root(), None).expect("routes");
263        assert_eq!(forward.author.id(), "a");
264        assert_eq!(forward.author.id(), reverse.author.id());
265        assert_eq!(forward.reviewer.id(), "b");
266    }
267
268    #[test]
269    fn a_profile_whose_cli_is_absent_is_not_routed_to() {
270        let missing = Profile::parse(
271            r#"
272            id = "aaa-missing"
273            command = "definitely-not-a-real-binary-xyz"
274            args = ["{{prompt}}"]
275            "#,
276        )
277        .expect("valid");
278        // "aaa-missing" sorts first, so id order alone would pick it.
279        let routing = select(
280            &[missing, profile("b"), profile("c")],
281            None,
282            None,
283            root(),
284            None,
285        )
286        .expect("routes");
287        assert_eq!(routing.author.id(), "b");
288        assert_eq!(routing.reviewer.id(), "c");
289    }
290
291    #[test]
292    fn an_unrunnable_choice_is_still_honoured_when_named() {
293        // Naming an adapter is a decision. Substituting a different one behind
294        // the caller's back would make the run record say something untrue.
295        let missing = Profile::parse(
296            r#"
297            id = "missing"
298            command = "definitely-not-a-real-binary-xyz"
299            args = ["{{prompt}}"]
300            "#,
301        )
302        .expect("valid");
303        let routing = select(
304            &[missing, profile("b")],
305            Some("missing"),
306            None,
307            root(),
308            None,
309        )
310        .expect("routes");
311        assert_eq!(routing.author.id(), "missing");
312    }
313
314    #[test]
315    fn when_nothing_is_runnable_the_reason_is_named() {
316        let missing = Profile::parse(
317            r#"
318            id = "gone"
319            command = "definitely-not-a-real-binary-xyz"
320            args = ["{{prompt}}"]
321            "#,
322        )
323        .expect("valid");
324        let err = select(&[missing], None, None, root(), None).expect_err("must refuse");
325        assert!(err.to_string().contains("definitely-not-a-real-binary-xyz"));
326    }
327
328    #[test]
329    fn an_unpicked_reviewer_prefers_a_different_binary_over_a_lower_id() {
330        // Two profiles of one vendor on two models are a legitimate pair, and
331        // the ids here would sort them together ahead of "zz". Chosen for
332        // someone rather than by them, the genuinely different vendor wins.
333        let same_vendor_a = command_profile("aa-vendor-fast", "true");
334        let same_vendor_b = command_profile("ab-vendor-slow", "true");
335        let other_vendor = command_profile("zz-other", "echo");
336
337        let routing = select(
338            &[same_vendor_a, same_vendor_b, other_vendor],
339            Some("aa-vendor-fast"),
340            None,
341            root(),
342            None,
343        )
344        .expect("routes");
345        assert_eq!(routing.reviewer.id(), "zz-other");
346    }
347
348    fn reviewing_profile(id: &str) -> Profile {
349        Profile::parse(&format!(
350            r#"
351            id = "{id}"
352            command = "true"
353            args = ["{{{{prompt}}}}", "--write"]
354            review_args = ["{{{{prompt}}}}", "--read-only"]
355            "#
356        ))
357        .expect("valid")
358    }
359
360    #[test]
361    fn an_unpicked_reviewer_is_one_with_a_review_invocation_first() {
362        // `a` sorts first and would have been the reviewer, but it has no
363        // review invocation, so it would review with the one that can write.
364        let profiles = [profile("a"), reviewing_profile("b"), profile("writer")];
365        let routing = select(&profiles, Some("writer"), None, root(), None).expect("routes");
366        assert_eq!(routing.reviewer.id(), "b");
367    }
368
369    #[test]
370    fn posture_outranks_a_different_binary_when_choosing_a_reviewer() {
371        // `other` is a different binary from the author and has no review
372        // invocation; `same` shares the author's binary and can only read.
373        // Whether a verdict can be trusted comes before how good it is.
374        let author = command_profile("author", "true");
375        let other = command_profile("other", "sh");
376        let same = reviewing_profile("same");
377        let routing =
378            select(&[author, other, same], Some("author"), None, root(), None).expect("routes");
379        assert_eq!(routing.reviewer.id(), "same");
380    }
381
382    #[test]
383    fn posture_is_not_asked_of_an_author() {
384        // Only a reviewer needs to be unable to write. Picking an author by
385        // whether it can review would push the one read-only profile into the
386        // wrong seat.
387        let profiles = [profile("a"), reviewing_profile("b")];
388        let routing = select(&profiles, None, None, root(), None).expect("routes");
389        assert_eq!(routing.author.id(), "a");
390        assert_eq!(routing.reviewer.id(), "b");
391    }
392
393    #[test]
394    fn a_writable_reviewer_is_still_used_when_it_is_the_only_one() {
395        // An ordering, not a refusal: the tree check in the orchestrator is what
396        // refuses a reviewer that actually writes.
397        let profiles = [profile("a"), profile("b")];
398        let routing = select(&profiles, Some("a"), None, root(), None).expect("routes");
399        assert_eq!(routing.reviewer.id(), "b");
400    }
401
402    #[test]
403    fn a_named_writable_reviewer_is_honoured() {
404        let profiles = [profile("a"), reviewing_profile("b"), profile("c")];
405        let routing = select(&profiles, Some("b"), Some("c"), root(), None).expect("routes");
406        assert_eq!(routing.reviewer.id(), "c");
407    }
408
409    #[test]
410    fn one_vendor_on_two_profiles_is_still_a_usable_pair() {
411        // The escape hatch has to actually work: with nothing else installed,
412        // two profiles of the same binary review each other rather than the run
413        // refusing outright.
414        let profiles = [
415            command_profile("vendor-fast", "true"),
416            command_profile("vendor-slow", "true"),
417        ];
418        let routing = select(&profiles, None, None, root(), None).expect("routes");
419        assert_eq!(routing.author.id(), "vendor-fast");
420        assert_eq!(routing.reviewer.id(), "vendor-slow");
421    }
422
423    #[test]
424    fn an_unknown_id_is_reported_by_name() {
425        let err =
426            select(&[profile("a")], Some("nope"), None, root(), None).expect_err("must refuse");
427        assert!(err.to_string().contains("nope"));
428    }
429}