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}