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    if profiles.is_empty() {
53        return Err(Error::Other("no adapter profiles found".to_string()));
54    }
55
56    let find = |id: &str| -> Result<Profile> {
57        profiles
58            .iter()
59            .find(|p| p.id == id)
60            .cloned()
61            .ok_or_else(|| Error::Other(format!("no adapter profile with id {id:?}")))
62    };
63
64    let (author, reviewer) = match (author_id, reviewer_id) {
65        (Some(a), Some(r)) => (find(a)?, find(r)?),
66        (Some(a), None) => {
67            let author = find(a)?;
68            let reviewer = first_other(profiles, Some(&author))?;
69            (author, reviewer)
70        }
71        (None, Some(r)) => {
72            let reviewer = find(r)?;
73            let author = first_other(profiles, Some(&reviewer))?;
74            (author, reviewer)
75        }
76        (None, None) => {
77            let author = first_other(profiles, None)?;
78            let reviewer = first_other(profiles, Some(&author))?;
79            (author, reviewer)
80        }
81    };
82
83    if author.id == reviewer.id {
84        return Err(Error::Other(format!(
85            "author and reviewer are the same adapter ({:?}); a change cannot review itself",
86            author.id
87        )));
88    }
89
90    Ok(Routing {
91        author: Box::new(
92            ProcessAdapter::new(author)
93                .isolated_under(isolation_root)
94                .within(timeout),
95        ),
96        reviewer: Box::new(
97            ProcessAdapter::reviewing(reviewer)
98                .isolated_under(isolation_root)
99                .within(timeout),
100        ),
101    })
102}
103
104/// The profile to pair with another one, or the first to pick when there is no
105/// other one yet.
106///
107/// Availability is asked, not assumed. A profile whose CLI is absent or refuses
108/// to answer would otherwise be routed to and fail after a worktree had been
109/// created and a task dispatched.
110///
111/// Among the usable ones, a profile invoking a *different binary* is preferred.
112/// Independence is expressed between profiles, so two profiles of one vendor on
113/// two models are a legitimate pair — but they share a lineage, a system prompt
114/// and a set of blind spots, and picking them over an actually different vendor
115/// because their ids happen to sort first would weaken every unattended run.
116/// Naming one explicitly still gets it: this orders a choice nobody made.
117fn first_other(profiles: &[Profile], exclude: Option<&Profile>) -> Result<Profile> {
118    let excluded_id = exclude.map(|p| p.id.as_str()).unwrap_or_default();
119    let excluded_command = exclude.map(|p| p.command.as_str());
120    let mut others: Vec<&Profile> = profiles.iter().filter(|p| p.id != excluded_id).collect();
121    others.sort_by(|a, b| {
122        let same = |p: &Profile| excluded_command == Some(p.command.as_str());
123        same(a).cmp(&same(b)).then_with(|| a.id.cmp(&b.id))
124    });
125
126    let mut unusable: Vec<String> = Vec::new();
127    for candidate in &others {
128        match ProcessAdapter::new((*candidate).clone()).probe() {
129            a if a.is_ready() => return Ok((*candidate).clone()),
130            Availability::NotFound { command } => {
131                unusable.push(format!("{}: {command} not on PATH", candidate.id));
132            }
133            Availability::Unusable { reason } => {
134                unusable.push(format!("{}: {reason}", candidate.id));
135            }
136            // `is_ready` covered this arm; kept exhaustive rather than
137            // unreachable so a new variant is a compile error, not a silent pass.
138            Availability::Ready { .. } => return Ok((*candidate).clone()),
139        }
140    }
141
142    if others.is_empty() {
143        return Err(Error::Other(
144            "only one adapter profile is configured, so no independent reviewer exists; \
145             add a second profile in adapters/"
146                .to_string(),
147        ));
148    }
149    let besides = if excluded_id.is_empty() {
150        String::new()
151    } else {
152        format!(" besides {excluded_id:?}")
153    };
154    Err(Error::Other(format!(
155        "no usable adapter profile{besides}; run `ostraka adapters` for detail. Checked — {}",
156        unusable.join("; ")
157    )))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    /// Routing never launches anything, so any path will do here.
165    fn root() -> &'static Path {
166        Path::new("/nonexistent-isolation-root")
167    }
168
169    fn command_profile(id: &str, command: &str) -> Profile {
170        Profile::parse(&format!(
171            r#"
172            id = "{id}"
173            command = "{command}"
174            args = ["{{{{prompt}}}}"]
175            "#
176        ))
177        .expect("valid")
178    }
179
180    fn profile(id: &str) -> Profile {
181        Profile::parse(&format!(
182            r#"
183            id = "{id}"
184            command = "true"
185            args = ["{{{{prompt}}}}"]
186            "#
187        ))
188        .expect("valid")
189    }
190
191    #[test]
192    fn a_lone_adapter_cannot_review_itself() {
193        let err = select(&[profile("solo")], None, None, root(), None).expect_err("must refuse");
194        assert!(err.to_string().contains("no independent reviewer"));
195    }
196
197    #[test]
198    fn naming_the_same_adapter_twice_is_refused() {
199        let profiles = [profile("a"), profile("b")];
200        let err = select(&profiles, Some("a"), Some("a"), root(), None).expect_err("must refuse");
201        assert!(err.to_string().contains("cannot review itself"));
202    }
203
204    #[test]
205    fn selection_is_deterministic_not_listing_order() {
206        let forward =
207            select(&[profile("b"), profile("a")], None, None, root(), None).expect("routes");
208        let reverse =
209            select(&[profile("a"), profile("b")], None, None, root(), None).expect("routes");
210        assert_eq!(forward.author.id(), "a");
211        assert_eq!(forward.author.id(), reverse.author.id());
212        assert_eq!(forward.reviewer.id(), "b");
213    }
214
215    #[test]
216    fn a_profile_whose_cli_is_absent_is_not_routed_to() {
217        let missing = Profile::parse(
218            r#"
219            id = "aaa-missing"
220            command = "definitely-not-a-real-binary-xyz"
221            args = ["{{prompt}}"]
222            "#,
223        )
224        .expect("valid");
225        // "aaa-missing" sorts first, so id order alone would pick it.
226        let routing = select(
227            &[missing, profile("b"), profile("c")],
228            None,
229            None,
230            root(),
231            None,
232        )
233        .expect("routes");
234        assert_eq!(routing.author.id(), "b");
235        assert_eq!(routing.reviewer.id(), "c");
236    }
237
238    #[test]
239    fn an_unrunnable_choice_is_still_honoured_when_named() {
240        // Naming an adapter is a decision. Substituting a different one behind
241        // the caller's back would make the run record say something untrue.
242        let missing = Profile::parse(
243            r#"
244            id = "missing"
245            command = "definitely-not-a-real-binary-xyz"
246            args = ["{{prompt}}"]
247            "#,
248        )
249        .expect("valid");
250        let routing = select(
251            &[missing, profile("b")],
252            Some("missing"),
253            None,
254            root(),
255            None,
256        )
257        .expect("routes");
258        assert_eq!(routing.author.id(), "missing");
259    }
260
261    #[test]
262    fn when_nothing_is_runnable_the_reason_is_named() {
263        let missing = Profile::parse(
264            r#"
265            id = "gone"
266            command = "definitely-not-a-real-binary-xyz"
267            args = ["{{prompt}}"]
268            "#,
269        )
270        .expect("valid");
271        let err = select(&[missing], None, None, root(), None).expect_err("must refuse");
272        assert!(err.to_string().contains("definitely-not-a-real-binary-xyz"));
273    }
274
275    #[test]
276    fn an_unpicked_reviewer_prefers_a_different_binary_over_a_lower_id() {
277        // Two profiles of one vendor on two models are a legitimate pair, and
278        // the ids here would sort them together ahead of "zz". Chosen for
279        // someone rather than by them, the genuinely different vendor wins.
280        let same_vendor_a = command_profile("aa-vendor-fast", "true");
281        let same_vendor_b = command_profile("ab-vendor-slow", "true");
282        let other_vendor = command_profile("zz-other", "echo");
283
284        let routing = select(
285            &[same_vendor_a, same_vendor_b, other_vendor],
286            Some("aa-vendor-fast"),
287            None,
288            root(),
289            None,
290        )
291        .expect("routes");
292        assert_eq!(routing.reviewer.id(), "zz-other");
293    }
294
295    #[test]
296    fn one_vendor_on_two_profiles_is_still_a_usable_pair() {
297        // The escape hatch has to actually work: with nothing else installed,
298        // two profiles of the same binary review each other rather than the run
299        // refusing outright.
300        let profiles = [
301            command_profile("vendor-fast", "true"),
302            command_profile("vendor-slow", "true"),
303        ];
304        let routing = select(&profiles, None, None, root(), None).expect("routes");
305        assert_eq!(routing.author.id(), "vendor-fast");
306        assert_eq!(routing.reviewer.id(), "vendor-slow");
307    }
308
309    #[test]
310    fn an_unknown_id_is_reported_by_name() {
311        let err =
312            select(&[profile("a")], Some("nope"), None, root(), None).expect_err("must refuse");
313        assert!(err.to_string().contains("nope"));
314    }
315}