Skip to main content

magi/
fixer.rs

1//! The `[roles] fixer` fallback chain, shared by every node that asks a fixer.
2//!
3//! Four 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 and the
5//! rebase conflict 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; 4] = ["fix", "gate-fix", "land", "rebase"];
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/// Where in `chain` a call starts: the agent the last fixer-node handover
81/// moved to, else the first.
82fn sticky_start(chain: &[AgentSpec], handovers: &[Handover]) -> usize {
83    handovers
84        .iter()
85        .rev()
86        .filter(|h| FIX_NODES.contains(&h.node.as_str()))
87        .find_map(|h| chain.iter().position(|s| s.id == h.to))
88        .unwrap_or(0)
89}
90
91/// Fetch or create the seat `key` for `agent`. The same agent continues its
92/// conversation; another agent takes a fresh seat whose session id is minted
93/// from the agent as well, so `agent::has_session` is false and the full
94/// context is sent again.
95pub fn seat_for(state: &mut RunState, key: &str, agent: &str) -> SeatState {
96    if let Some(existing) = state.seats.get(key)
97        && existing.agent == agent
98    {
99        return existing.clone();
100    }
101    let fresh = if state.seats.contains_key(key) {
102        crate::graph::handover_seat(key, agent, state.next_seat_seed())
103    } else {
104        SeatState::new(key, agent, state.seed)
105    };
106    state.seats.insert(key.to_owned(), fresh.clone());
107    fresh
108}
109
110/// How a call that did not satisfy the chain failed, and a short reason, for
111/// the handover record (`land` / `rebase` call the CLI directly).
112pub fn failure_of(out: &anyhow::Result<AgentOutput>) -> (FailClass, String) {
113    match out {
114        Err(e) => (
115            FailClass::Other("error".to_owned()),
116            format!("{e:#}")
117                .lines()
118                .next()
119                .unwrap_or("")
120                .chars()
121                .take(160)
122                .collect(),
123        ),
124        Ok(o) if o.quota_exhausted() => (FailClass::Quota, "rate limited (quota)".to_owned()),
125        Ok(o) if o.timed_out => (FailClass::Timeout, "timed out".to_owned()),
126        Ok(o) => (
127            FailClass::Other("unusable".to_owned()),
128            format!("nothing usable (exit {:?})", o.exit_code),
129        ),
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::config::AgentKind;
137    use jiff::Timestamp;
138    use std::collections::BTreeMap;
139
140    fn spec(id: &str) -> AgentSpec {
141        AgentSpec {
142            id: id.to_owned(),
143            kind: AgentKind::Command,
144            model: None,
145            command: vec!["true".to_owned()],
146            extra_args: Vec::new(),
147            env: BTreeMap::new(),
148            prompt_delivery: None,
149        }
150    }
151
152    fn handover(node: &str, to: &str) -> Handover {
153        Handover {
154            at: Timestamp::now(),
155            node: node.to_owned(),
156            seat: "fix".to_owned(),
157            from: "x".to_owned(),
158            to: to.to_owned(),
159            reason: String::new(),
160        }
161    }
162
163    #[test]
164    fn the_start_follows_the_last_fixer_handover_on_the_chain() {
165        let chain = [spec("a"), spec("b"), spec("c")];
166        assert_eq!(sticky_start(&chain, &[]), 0);
167        // A reviewer's handover is not a fixer's.
168        assert_eq!(sticky_start(&chain, &[handover("review", "c")]), 0);
169        // An agent off the chain is ignored.
170        assert_eq!(sticky_start(&chain, &[handover("fix", "z")]), 0);
171        assert_eq!(
172            sticky_start(&chain, &[handover("fix", "b"), handover("rebase", "c")]),
173            2
174        );
175        assert_eq!(
176            sticky_start(&chain, &[handover("fix", "c"), handover("land", "a")]),
177            0
178        );
179    }
180}