Skip to main content

magi/
fixer.rs

1//! The `[roles] fixer` fallback chain, shared by every node that asks a fixer.
2//!
3//! Five nodes turn a fixer into a seat: the review loop's fix rounds (and the
4//! operator-selected fix), the gate-fix round, `land`'s fix rounds, the
5//! rebase conflict round and the graft round. Each of them runs a plain forward `for` over
6//! [`attempts`] and moves to the next entry only when the call advances
7//! ([`crate::agent::output_advances`], the same decision point the other
8//! chained roles use); this module holds what they would otherwise each
9//! write and drift on: the order, the seat of each entry, and what a
10//! handover looks like in the record.
11//!
12//! **Sticky within a run, never persisted.** Once a fallback agent answered a
13//! fixer node, later calls in the same run start from it, so its session
14//! carries across rounds instead of the first agent being re-asked (and its
15//! quota re-billed) every round. The memory is the run's own
16//! [`RunState::handovers`]: the last handover on a [`FIX_NODES`] node whose
17//! `to` is on the chain. The report and the behaviour therefore read one
18//! record, and nothing is written to the config. A call runs from the start to
19//! the end of the chain, forward only: entries before the start are not
20//! asked again, so the chain's last failure ends the call as it always did.
21//!
22//! A fallback agent always gets a fresh [`SeatState`] ([`seat_for`]), never
23//! the previous agent's seat renamed: its session id is minted from the agent
24//! too, because the previous agent's uuid is already taken by the CLI.
25
26use crate::agent::{AgentOutput, SeatState};
27use crate::config::{AgentSpec, ResolvedRoles};
28use crate::run::{Candidate, FailClass, Handover, RunState};
29
30/// The nodes whose handovers [`attempts`] reads as "who fixes now". Every
31/// consumer records its handovers under exactly one of these, so the two sides
32/// cannot disagree: a name here that nobody records would send every round
33/// back to the first agent and bill its quota again.
34pub const FIX_NODES: [&str; 5] = ["fix", "gate-fix", "land", "rebase", "graft"];
35
36/// The agents to ask, in order, each with the seat key it sits in.
37///
38/// With `[roles] fixer` unset: the winner's own implementer, whose
39/// conversation continues now that the competition is over (one entry, as it
40/// has always been). With a chain: the entries from the sticky agent to the
41/// end, once each, never wrapping. The chain's *first* entry, when it is the
42/// winner's own agent, sits in the winner's seat (`impl-<label>`), as a lone
43/// fixer always has; every other entry - a fallback, even one that is the
44/// winner's author - sits in the shared `fix` seat, which `seat_for` gives a
45/// fresh session (the implementation conversation is never reused by a
46/// handover).
47pub fn attempts(
48    state: &RunState,
49    roles: &ResolvedRoles,
50    winner: &Candidate,
51) -> Vec<(AgentSpec, String)> {
52    let own = || {
53        (
54            state
55                .config
56                .agent(&winner.agent)
57                .cloned()
58                .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
59            format!("impl-{}", winner.label),
60        )
61    };
62    let Some(chain) = roles.fixer.as_deref().filter(|c| !c.is_empty()) else {
63        return vec![own()];
64    };
65    let start = sticky_start(chain, &state.handovers);
66    chain
67        .iter()
68        .enumerate()
69        .skip(start)
70        .map(|(i, f)| {
71            if i == 0 && f.id == winner.agent {
72                own()
73            } else {
74                (f.clone(), "fix".to_owned())
75            }
76        })
77        .collect()
78}
79
80/// [`attempts`] for the graft round: the same agents in the same order, but
81/// every entry sits in the one `graft` seat - never the winner's `impl-<label>`
82/// conversation, which remembers the implementation. A fresh seat per agent
83/// ([`seat_for`] semantics) keeps what the winner's author knew out of a round
84/// that is told about candidates by label only.
85pub fn graft_attempts(
86    state: &RunState,
87    roles: &ResolvedRoles,
88    winner: &Candidate,
89) -> Vec<(AgentSpec, String)> {
90    attempts(state, roles, winner)
91        .into_iter()
92        .map(|(spec, _)| (spec, "graft".to_owned()))
93        .collect()
94}
95
96/// Where in `chain` a call starts: the agent the last fixer-node handover
97/// moved to, else the first.
98fn sticky_start(chain: &[AgentSpec], handovers: &[Handover]) -> usize {
99    handovers
100        .iter()
101        .rev()
102        .filter(|h| FIX_NODES.contains(&h.node.as_str()))
103        .find_map(|h| chain.iter().position(|s| s.id == h.to))
104        .unwrap_or(0)
105}
106
107/// Fetch or create the seat `key` for `agent`. The same agent continues its
108/// conversation; another agent takes a fresh seat whose session id is minted
109/// from the agent as well, so `agent::has_session` is false and the full
110/// context is sent again.
111pub fn seat_for(state: &mut RunState, key: &str, agent: &str) -> SeatState {
112    if let Some(existing) = state.seats.get(key)
113        && existing.agent == agent
114    {
115        return existing.clone();
116    }
117    let fresh = if state.seats.contains_key(key) {
118        crate::graph::handover_seat(key, agent, state.next_seat_seed())
119    } else {
120        SeatState::new(key, agent, state.seed)
121    };
122    state.seats.insert(key.to_owned(), fresh.clone());
123    fresh
124}
125
126/// How a call that did not satisfy the chain failed, and a short reason, for
127/// the handover record (`land` / `rebase` call the CLI directly).
128pub fn failure_of(out: &anyhow::Result<AgentOutput>) -> (FailClass, String) {
129    match out {
130        Err(e) => (
131            FailClass::Other("error".to_owned()),
132            format!("{e:#}")
133                .lines()
134                .next()
135                .unwrap_or("")
136                .chars()
137                .take(160)
138                .collect(),
139        ),
140        Ok(o) if o.quota_exhausted() => (FailClass::Quota, "rate limited (quota)".to_owned()),
141        Ok(o) if o.timed_out => (FailClass::Timeout, "timed out".to_owned()),
142        Ok(o) => (
143            FailClass::Other("unusable".to_owned()),
144            format!("nothing usable (exit {:?})", o.exit_code),
145        ),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::config::AgentKind;
153    use jiff::Timestamp;
154    use std::collections::BTreeMap;
155
156    fn spec(id: &str) -> AgentSpec {
157        AgentSpec {
158            id: id.to_owned(),
159            kind: AgentKind::Command,
160            model: None,
161            command: vec!["true".to_owned()],
162            extra_args: Vec::new(),
163            env: BTreeMap::new(),
164            prompt_delivery: None,
165        }
166    }
167
168    fn handover(node: &str, to: &str) -> Handover {
169        Handover {
170            at: Timestamp::now(),
171            node: node.to_owned(),
172            seat: "fix".to_owned(),
173            from: "x".to_owned(),
174            to: to.to_owned(),
175            reason: String::new(),
176        }
177    }
178
179    #[test]
180    fn the_start_follows_the_last_fixer_handover_on_the_chain() {
181        let chain = [spec("a"), spec("b"), spec("c")];
182        assert_eq!(sticky_start(&chain, &[]), 0);
183        // A reviewer's handover is not a fixer's.
184        assert_eq!(sticky_start(&chain, &[handover("review", "c")]), 0);
185        // An agent off the chain is ignored.
186        assert_eq!(sticky_start(&chain, &[handover("fix", "z")]), 0);
187        assert_eq!(
188            sticky_start(&chain, &[handover("fix", "b"), handover("rebase", "c")]),
189            2
190        );
191        assert_eq!(
192            sticky_start(&chain, &[handover("fix", "c"), handover("land", "a")]),
193            0
194        );
195        // A graft handover is a fixer handover too.
196        assert_eq!(sticky_start(&chain, &[handover("graft", "b")]), 1);
197    }
198}