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}