Skip to main content

car_server_core/coder/
heal_config.rs

1//! Which repositories the self-healing loop watches, from `~/.car/heal.toml`.
2//!
3//! T6 of `docs/proposals/self-healing-issue-loop.md`. Same idiom as
4//! [`super::config`]: a small, tolerant TOML file the operator drops next to
5//! the coder state dir, so adding a repository is an edit rather than a
6//! recompile.
7//!
8//! ```toml
9//! # Nothing runs until a target is listed. An absent or empty file is a
10//! # disabled loop, which is the right default for a feature that opens pull
11//! # requests.
12//! [[target]]
13//! repo = "Parslee-ai/car"
14//! project = "car"              # a CAR-managed clone, OR:
15//! # path = "/Users/me/git/car" # a checkout you already have
16//! label = "self-heal"          # opt-in label; items without it are invisible
17//!
18//! [[target]]
19//! repo = "Parslee-ai/car-releases"
20//! # Issues live here; fixes land on the SOURCE repo. Without this, the
21//! # "is it already covered?" check queries a tracker that holds no pull
22//! # requests, and the item is never covered — forever.
23//! fix_repo = "Parslee-ai/car"
24//! # no project/path -> watch-only: the loop reads the queue and can act on
25//! # nothing, which is the honest state for a tracker with no source.
26//! label = "self-heal"
27//! ```
28//!
29//! ## Every default here fails closed
30//!
31//! An unreadable file disables the loop rather than falling back to a built-in
32//! target list. The failure modes are not symmetric: a loop that does nothing
33//! is visible the moment someone looks for a pull request, while a loop that
34//! runs against a repository the operator did not name is a surprise in
35//! somebody else's tracker. There is no compiled-in default target, and
36//! `car`/`car-releases` are configuration like anything else.
37
38use serde::Deserialize;
39
40use super::heal_intake::{is_valid_repo_spec, Checkout, HealTarget};
41
42/// File under `CAR_HOME`.
43pub const HEAL_CONFIG_FILE: &str = "heal.toml";
44
45/// The default opt-in label when a target does not name one.
46pub const DEFAULT_LABEL: &str = "self-heal";
47
48/// The default base branch for a target that does not name one.
49pub const DEFAULT_BASE: &str = "main";
50
51#[derive(Debug, Deserialize, Default)]
52struct RawConfig {
53    #[serde(default)]
54    target: Vec<RawTarget>,
55    /// The review panel: model ids, one seat each.
56    #[serde(default)]
57    review_models: Vec<String>,
58    /// Which engine performs the coding work.
59    #[serde(default)]
60    engine: Option<String>,
61    /// Pin the coder's model.
62    #[serde(default)]
63    coder_model: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67struct RawTarget {
68    repo: String,
69    #[serde(default)]
70    fix_repo: Option<String>,
71    #[serde(default)]
72    project: Option<String>,
73    #[serde(default)]
74    path: Option<std::path::PathBuf>,
75    #[serde(default)]
76    label: Option<String>,
77    /// The branch a fix's pull request merges into. Defaults to
78    /// [`DEFAULT_BASE`]; a repository whose default branch is `master` must say
79    /// so, because the loop cannot open a pull request against a base that does
80    /// not exist.
81    #[serde(default)]
82    base: Option<String>,
83}
84
85/// A target that was named but could not be used, and why.
86///
87/// Surfaced rather than dropped: a typo in a repo spec that silently removed a
88/// target would present as "the loop never does anything", which is the hardest
89/// class of bug to notice in something whose normal state is idle.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RejectedTarget {
92    pub repo: String,
93    pub reason: String,
94}
95
96/// The loaded configuration.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct HealConfig {
99    pub targets: Vec<HealTarget>,
100    pub rejected: Vec<RejectedTarget>,
101    /// The review panel. Empty disables the loop — see [`Self::is_enabled`].
102    pub review_models: Vec<String>,
103    /// Which engine performs the coding work, as `EngineChoice::parse` reads
104    /// it. `None` means the compiled default.
105    ///
106    /// Configurable because `auto` decides per task from the external CLIs
107    /// installed on the machine, so an unattended loop would silently run a
108    /// different engine on a laptop than on a server.
109    pub engine: Option<String>,
110    /// The coder's model, pinned. `None` uses `coder.toml` then adaptive
111    /// routing — which picks a provider per request, so an unattended loop can
112    /// spend a full session against an expired credential while a working one
113    /// sits beside it.
114    ///
115    /// `None` is therefore NOT the same as "unpinned", and the assembly-time
116    /// "the coder may not sit on the panel" check reads both sources for that
117    /// reason (`heal_service::check_coder_pin`, car#1360).
118    /// Only when neither file pins does the router choose, and then only the
119    /// runtime gate on `authored_by` can catch a coder that is also a seat.
120    pub coder_model: Option<String>,
121}
122
123impl HealConfig {
124    /// Whether the loop has anything to do, and anything to check it with.
125    ///
126    /// A panel is not optional. [`super::heal_gate::decide`] refuses a panel of
127    /// zero, so targets without `review_models` would produce a loop that runs
128    /// a full coder session per item and then rejects every one of them —
129    /// burning inference to reach a foregone conclusion, and reporting the gate
130    /// as the reason. Refusing to start says the same thing for free.
131    pub fn is_enabled(&self) -> bool {
132        !self.targets.is_empty() && !self.review_models.is_empty()
133    }
134
135    /// Why the loop is not running, when it is configured but disabled.
136    ///
137    /// An idle loop and a misconfigured one look identical from outside, and
138    /// the misconfigured one is the common case on first setup.
139    pub fn disabled_reason(&self) -> Option<&'static str> {
140        if self.targets.is_empty() {
141            Some("no usable targets are configured")
142        } else if self.review_models.is_empty() {
143            Some(
144                "no `review_models` are configured; the loop will not run a coder session it has no panel to check",
145            )
146        } else {
147            None
148        }
149    }
150
151    /// Load from `dir/heal.toml`. A missing file is an empty, disabled config.
152    pub fn load(dir: &std::path::Path) -> Self {
153        let path = dir.join(HEAL_CONFIG_FILE);
154        let Ok(raw) = std::fs::read_to_string(&path) else {
155            return Self::default();
156        };
157        Self::parse(&raw)
158    }
159
160    /// Parse, keeping the usable targets and reporting the rest.
161    ///
162    /// A malformed *file* disables the loop; one malformed *target* removes
163    /// only itself. Those differ because a broken file gives no basis to guess
164    /// intent, while a broken entry sits beside entries whose intent is clear,
165    /// and refusing all of them would make one typo disable a working setup.
166    pub fn parse(raw: &str) -> Self {
167        let parsed: RawConfig = match toml::from_str(raw) {
168            Ok(c) => c,
169            Err(e) => {
170                tracing::warn!(error = %e, "unreadable heal.toml; the self-healing loop is disabled");
171                return Self::default();
172            }
173        };
174
175        let mut targets = Vec::new();
176        let mut rejected = Vec::new();
177        for t in parsed.target {
178            if !is_valid_repo_spec(&t.repo) {
179                rejected.push(RejectedTarget {
180                    repo: t.repo,
181                    reason: "not a valid owner/name repository spec".into(),
182                });
183                continue;
184            }
185            if t.project.is_some() && t.path.is_some() {
186                rejected.push(RejectedTarget {
187                    repo: t.repo,
188                    reason: "set `project` or `path`, not both — they name different \
189                             owners of the working tree"
190                        .into(),
191                });
192                continue;
193            }
194            let checkout = match (t.project, t.path) {
195                (Some(p), None) => Some(Checkout::Project(p)),
196                (None, Some(p)) => Some(Checkout::Local(p)),
197                // Neither is legal and means watch-only.
198                (None, None) => None,
199                (Some(_), Some(_)) => unreachable!("rejected above"),
200            };
201            if let Some(fr) = &t.fix_repo {
202                if !is_valid_repo_spec(fr) {
203                    rejected.push(RejectedTarget {
204                        repo: t.repo,
205                        reason: format!("`fix_repo` {fr:?} is not a valid owner/name spec"),
206                    });
207                    continue;
208                }
209            }
210            targets.push(HealTarget {
211                repo: t.repo,
212                fix_repo: t.fix_repo,
213                checkout,
214                label: t.label.unwrap_or_else(|| DEFAULT_LABEL.to_string()),
215                base: t
216                    .base
217                    .filter(|b| !b.trim().is_empty())
218                    .unwrap_or_else(|| DEFAULT_BASE.to_string()),
219            });
220        }
221        Self {
222            targets,
223            rejected,
224            review_models: parsed
225                .review_models
226                .into_iter()
227                .map(|m| m.trim().to_string())
228                .filter(|m| !m.is_empty())
229                .collect(),
230            engine: parsed
231                .engine
232                .map(|e| e.trim().to_string())
233                .filter(|e| !e.is_empty()),
234            // TRIMMED, like `review_models` above. Filtering on the trimmed
235            // value while keeping the untrimmed one let `coder_model =
236            // "gpt-5.5 "` survive, miss the registry, and slip past the check
237            // that a coder may not sit on its own review panel — a model
238            // reviewing its own output because of a stray space.
239            coder_model: parsed
240                .coder_model
241                .map(|m| m.trim().to_string())
242                .filter(|m| !m.is_empty()),
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    /// A stray space must not defeat the coder-on-panel check.
252    #[test]
253    fn coder_model_and_engine_are_trimmed_not_merely_tested_for_blankness() {
254        let c = HealConfig::parse(
255            r#"
256review_models = ["reviewer-a"]
257coder_model = "  gpt-5.5  "
258engine = "  native  "
259[[targets]]
260repo = "acme/one"
261"#,
262        );
263        assert_eq!(c.coder_model.as_deref(), Some("gpt-5.5"));
264        assert_eq!(c.engine.as_deref(), Some("native"));
265    }
266
267    #[test]
268    fn an_absent_file_disables_the_loop() {
269        let dir = tempfile::tempdir().unwrap();
270        let c = HealConfig::load(dir.path());
271        assert!(!c.is_enabled(), "no compiled-in targets");
272        assert!(c.targets.is_empty());
273    }
274
275    #[test]
276    fn there_is_no_built_in_target_list() {
277        // The loop opens pull requests. Running against a repository nobody
278        // named is a surprise in somebody else's tracker.
279        let c = HealConfig::parse("");
280        assert!(c.targets.is_empty());
281    }
282
283    #[test]
284    fn a_project_target_parses() {
285        let c = HealConfig::parse(
286            r#"
287            [[target]]
288            repo = "Parslee-ai/car"
289            project = "car"
290            label = "self-heal"
291            "#,
292        );
293        assert_eq!(c.targets.len(), 1);
294        assert_eq!(c.targets[0].repo, "Parslee-ai/car");
295        assert_eq!(c.targets[0].checkout, Some(Checkout::Project("car".into())));
296        assert!(c.targets[0].can_write());
297    }
298
299    #[test]
300    fn a_target_with_no_checkout_is_watch_only() {
301        let c = HealConfig::parse(
302            r#"
303            [[target]]
304            repo = "Parslee-ai/car-releases"
305            "#,
306        );
307        assert_eq!(c.targets.len(), 1);
308        assert!(!c.targets[0].can_write());
309        assert_eq!(c.targets[0].label, DEFAULT_LABEL);
310    }
311
312    #[test]
313    fn project_and_path_together_are_refused() {
314        // They name different owners of the working tree; guessing which the
315        // operator meant is how a loop writes somewhere unexpected.
316        let c = HealConfig::parse(
317            r#"
318            [[target]]
319            repo = "acme/widgets"
320            project = "widgets"
321            path = "/tmp/widgets"
322            "#,
323        );
324        assert!(c.targets.is_empty());
325        assert_eq!(c.rejected.len(), 1);
326        assert!(c.rejected[0].reason.contains("not both"));
327    }
328
329    #[test]
330    fn a_bad_repo_spec_is_reported_not_silently_dropped() {
331        // A typo that silently removed a target presents as "the loop never
332        // does anything", which is the hardest bug to notice in something whose
333        // normal state is idle.
334        let c = HealConfig::parse(
335            r#"
336            [[target]]
337            repo = "not-a-spec"
338            project = "x"
339            "#,
340        );
341        assert!(c.targets.is_empty());
342        assert_eq!(c.rejected[0].repo, "not-a-spec");
343    }
344
345    #[test]
346    fn one_bad_target_does_not_disable_the_good_ones() {
347        let c = HealConfig::parse(
348            r#"
349            review_models = ["a", "b"]
350
351            [[target]]
352            repo = "bad spec"
353
354            [[target]]
355            repo = "acme/widgets"
356            project = "widgets"
357            "#,
358        );
359        assert_eq!(c.targets.len(), 1);
360        assert_eq!(c.rejected.len(), 1);
361        assert!(c.is_enabled());
362        assert_eq!(c.disabled_reason(), None);
363    }
364
365    #[test]
366    fn targets_without_a_review_panel_do_not_enable_the_loop() {
367        // `heal_gate::decide` refuses a panel of zero, so this configuration
368        // would run a full coder session per item and then reject every one of
369        // them — spending inference to reach a foregone conclusion and
370        // reporting the gate as the reason. Refusing to start says the same
371        // thing for free, and names itself.
372        let c = HealConfig::parse(
373            r#"
374            [[target]]
375            repo = "acme/widgets"
376            project = "widgets"
377            "#,
378        );
379        assert_eq!(c.targets.len(), 1);
380        assert!(!c.is_enabled());
381        assert!(c.disabled_reason().unwrap().contains("review_models"));
382    }
383
384    #[test]
385    fn the_panel_and_the_engine_are_read_from_the_file() {
386        let c = HealConfig::parse(
387            r#"
388            review_models = ["claude-opus-5", "  ", "gpt-5.5"]
389            engine = "native"
390
391            [[target]]
392            repo = "acme/widgets"
393            project = "widgets"
394            base = "master"
395            "#,
396        );
397        // A blank entry would be a seat that can never answer, which the gate
398        // reads as an unreachable panel member and refuses the item over.
399        assert_eq!(c.review_models, vec!["claude-opus-5", "gpt-5.5"]);
400        assert_eq!(c.engine.as_deref(), Some("native"));
401        // The base is per target: `main` was hardcoded, and on a `master`
402        // repository the pull request was opened against a branch that does
403        // not exist.
404        assert_eq!(c.targets[0].base, "master");
405    }
406
407    #[test]
408    fn an_unnamed_base_defaults_rather_than_being_empty() {
409        let c = HealConfig::parse(
410            r#"
411            review_models = ["a"]
412
413            [[target]]
414            repo = "acme/widgets"
415            project = "widgets"
416            base = "   "
417            "#,
418        );
419        // An empty base reaches `deliver_pr` as a branch name git would
420        // misread, and the refusal names the wrong thing.
421        assert_eq!(c.targets[0].base, DEFAULT_BASE);
422    }
423
424    #[test]
425    fn a_malformed_file_disables_the_loop_entirely() {
426        // No basis to guess intent, and the safe reading of "I cannot parse
427        // your instructions" is to do nothing.
428        let c = HealConfig::parse("[[target]\nrepo = ");
429        assert!(!c.is_enabled());
430    }
431
432    #[test]
433    fn per_target_labels_are_independent() {
434        let c = HealConfig::parse(
435            r#"
436            [[target]]
437            repo = "acme/widgets"
438            project = "widgets"
439            label = "auto-fix"
440
441            [[target]]
442            repo = "acme/gadgets"
443            project = "gadgets"
444            "#,
445        );
446        assert_eq!(c.targets[0].label, "auto-fix");
447        assert_eq!(c.targets[1].label, DEFAULT_LABEL);
448    }
449    #[test]
450    fn a_cross_repo_target_carries_where_fixes_land() {
451        // Issues on a public tracker, code in the source repo. Without this the
452        // coverage check queries a repository that holds no pull requests, and
453        // the item is never covered — every daemon, every tick, forever.
454        let c = HealConfig::parse(
455            r#"
456            [[target]]
457            repo = "Parslee-ai/car-releases"
458            fix_repo = "Parslee-ai/car"
459            project = "car"
460            "#,
461        );
462        assert_eq!(c.targets.len(), 1);
463        assert_eq!(c.targets[0].coverage_repo(), "Parslee-ai/car");
464        assert!(c.targets[0].is_cross_repo());
465    }
466
467    #[test]
468    fn a_bad_fix_repo_is_reported_not_ignored() {
469        let c = HealConfig::parse(
470            r#"
471            [[target]]
472            repo = "acme/tracker"
473            fix_repo = "not a spec"
474            project = "x"
475            "#,
476        );
477        assert!(c.targets.is_empty());
478        assert!(c.rejected[0].reason.contains("fix_repo"));
479    }
480}