Skip to content

Commit a4a528e

Browse files
AlexMikhalevTerraphim CITerraphim AI
authored
fix: share build cache across agent worktrees (#763)
* fix: share build cache across agent worktrees Set CARGO_TARGET_DIR to main repo's target/ for all spawned agents so worktree builds reuse the existing compilation cache instead of building from scratch. Also detect sccache and set RUSTC_WRAPPER for cross-worktree content-addressed caching. Without this, each worktree agent triggered a full cargo build from zero (30+ minutes) because git worktrees get empty target/. Co-Authored-By: Terraphim AI <noreply@terraphim.ai> * fix: per-model health tracking instead of provider aggregate Circuit breakers now keyed by provider:model (e.g., anthropic:claude-opus-4-6) instead of just provider name. If opus is down but sonnet works, only opus is marked unhealthy -- anthropic provider stays healthy. Added: model_health(), is_model_healthy(), record_model_success(), record_model_failure() for per-model operations. provider_health() and is_healthy() still work at aggregate level: provider is healthy if ANY of its models is healthy. unhealthy_providers() reports a provider only if ALL its models are down. Fixes HIGH finding from compound-review #413. Co-Authored-By: Terraphim AI <noreply@terraphim.ai> --------- Co-authored-by: Terraphim CI <alex@terraphim.ai> Co-authored-by: Terraphim AI <noreply@terraphim.ai>
1 parent d0611cc commit a4a528e

2 files changed

Lines changed: 201 additions & 53 deletions

File tree

crates/terraphim_orchestrator/src/lib.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,27 @@ fn validate_agent_name(name: &str) -> Result<(), OrchestratorError> {
217217
impl AgentOrchestrator {
218218
/// Create a new orchestrator from configuration.
219219
pub fn new(config: OrchestratorConfig) -> Result<Self, OrchestratorError> {
220-
let spawner = AgentSpawner::new().with_working_dir(&config.working_dir);
220+
// Set CARGO_TARGET_DIR so worktree agents share the main build cache,
221+
// and RUSTC_WRAPPER=sccache for cross-worktree compilation caching.
222+
let mut spawn_env = std::collections::HashMap::new();
223+
let target_dir = config.working_dir.join("target");
224+
spawn_env.insert(
225+
"CARGO_TARGET_DIR".to_string(),
226+
target_dir.to_string_lossy().to_string(),
227+
);
228+
if std::process::Command::new("sccache")
229+
.arg("--version")
230+
.stdout(std::process::Stdio::null())
231+
.stderr(std::process::Stdio::null())
232+
.status()
233+
.is_ok()
234+
{
235+
spawn_env.insert("RUSTC_WRAPPER".to_string(), "sccache".to_string());
236+
info!("sccache detected, enabling shared compilation cache for worktrees");
237+
}
238+
let spawner = AgentSpawner::new()
239+
.with_working_dir(&config.working_dir)
240+
.with_env_vars(spawn_env);
221241
let router = RoutingEngine::new();
222242
let nightwatch = NightwatchMonitor::new(config.nightwatch.clone());
223243
let scheduler = TimeScheduler::new(&config.agents, Some(&config.compound_review.schedule))?;

crates/terraphim_orchestrator/src/provider_probe.rs

Lines changed: 180 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,12 @@ impl ProviderHealthMap {
114114
}
115115
}
116116

117-
// Update circuit breakers from probe results
117+
// Update circuit breakers from probe results (keyed by provider:model)
118118
for result in &results {
119+
let key = format!("{}:{}", result.provider, result.model);
119120
let breaker = self
120121
.breakers
121-
.entry(result.provider.clone())
122+
.entry(key)
122123
.or_insert_with(|| CircuitBreaker::new(self.cb_config.clone()));
123124

124125
match result.status {
@@ -140,106 +141,197 @@ impl ProviderHealthMap {
140141
self.probed_at = Some(Instant::now());
141142
}
142143

143-
/// Get health status for a provider.
144+
/// Get health status for a specific provider+model combination.
144145
///
145-
/// Uses **probe results first**: if the latest probe for this provider
146-
/// failed or timed out, it's unhealthy regardless of circuit breaker state.
147-
/// Falls back to circuit breaker for providers not recently probed.
148-
pub fn provider_health(&self, provider: &str) -> HealthStatus {
149-
// Check latest probe results (most authoritative)
150-
if let Some(status) = self.latest_probe_status(provider) {
151-
return match status {
146+
/// Uses **probe results first** at the model level, then falls back to
147+
/// circuit breaker. This is per-model, not per-provider aggregate.
148+
pub fn model_health(&self, provider: &str, model: &str) -> HealthStatus {
149+
let key = format!("{provider}:{model}");
150+
151+
// Check latest probe result for this exact model
152+
if let Some(result) = self
153+
.results
154+
.iter()
155+
.find(|r| r.provider == provider && r.model == model)
156+
{
157+
return match result.status {
152158
ProbeStatus::Success => HealthStatus::Healthy,
153159
ProbeStatus::Error => HealthStatus::Unhealthy,
154160
ProbeStatus::Timeout => HealthStatus::Unhealthy,
155161
};
156162
}
157163

158-
// Fall back to circuit breaker for unprobed providers
159-
match self.breakers.get(provider) {
164+
// Fall back to circuit breaker (keyed by provider:model)
165+
match self.breakers.get(&key) {
160166
Some(breaker) => match breaker.state() {
161167
CircuitState::Closed => HealthStatus::Healthy,
162168
CircuitState::HalfOpen => HealthStatus::Degraded,
163169
CircuitState::Open => HealthStatus::Unhealthy,
164170
},
165-
None => HealthStatus::Healthy, // Unknown providers assumed healthy
171+
None => HealthStatus::Healthy,
172+
}
173+
}
174+
175+
/// Get aggregate health status for a provider (any model healthy = provider healthy).
176+
pub fn provider_health(&self, provider: &str) -> HealthStatus {
177+
// Check probe results at model level
178+
let provider_results: Vec<_> = self
179+
.results
180+
.iter()
181+
.filter(|r| r.provider == provider)
182+
.collect();
183+
184+
if !provider_results.is_empty() {
185+
if provider_results
186+
.iter()
187+
.any(|r| r.status == ProbeStatus::Success)
188+
{
189+
return HealthStatus::Healthy;
190+
}
191+
return HealthStatus::Unhealthy;
192+
}
193+
194+
// Fall back to circuit breakers for this provider
195+
let provider_breakers: Vec<_> = self
196+
.breakers
197+
.iter()
198+
.filter(|(k, _)| k.starts_with(&format!("{provider}:")))
199+
.collect();
200+
201+
if provider_breakers.is_empty() {
202+
return HealthStatus::Healthy; // Unknown
203+
}
204+
205+
if provider_breakers.iter().any(|(_, b)| b.should_allow()) {
206+
HealthStatus::Healthy
207+
} else {
208+
HealthStatus::Unhealthy
166209
}
167210
}
168211

169212
/// Check if a provider is healthy enough to dispatch to.
170-
///
171-
/// A provider is healthy if its latest probe succeeded OR it wasn't probed
172-
/// and the circuit breaker allows requests.
173213
pub fn is_healthy(&self, provider: &str) -> bool {
174214
matches!(
175215
self.provider_health(provider),
176216
HealthStatus::Healthy | HealthStatus::Degraded
177217
)
178218
}
179219

180-
/// List all unhealthy provider names (from probe results + circuit breakers).
220+
/// Check if a specific model is healthy.
221+
pub fn is_model_healthy(&self, provider: &str, model: &str) -> bool {
222+
matches!(
223+
self.model_health(provider, model),
224+
HealthStatus::Healthy | HealthStatus::Degraded
225+
)
226+
}
227+
228+
/// List all unhealthy provider names.
229+
///
230+
/// A provider is unhealthy only if ALL its models are unhealthy.
181231
pub fn unhealthy_providers(&self) -> Vec<String> {
182-
let mut unhealthy: Vec<String> = Vec::new();
232+
let mut providers: HashMap<String, (usize, usize)> = HashMap::new(); // (total, healthy)
183233

184-
// From probe results: any provider with failed/timeout probe
185234
for result in &self.results {
186-
if result.status != ProbeStatus::Success && !unhealthy.contains(&result.provider) {
187-
unhealthy.push(result.provider.clone());
235+
let entry = providers.entry(result.provider.clone()).or_insert((0, 0));
236+
entry.0 += 1;
237+
if result.status == ProbeStatus::Success {
238+
entry.1 += 1;
188239
}
189240
}
190241

191-
// From circuit breakers: any open circuit not already in list
192-
for (name, breaker) in &self.breakers {
193-
if !breaker.should_allow() && !unhealthy.contains(name) {
194-
unhealthy.push(name.clone());
195-
}
196-
}
197-
198-
unhealthy
199-
}
200-
201-
/// Get the latest probe status for a provider (best result across all models).
202-
fn latest_probe_status(&self, provider: &str) -> Option<ProbeStatus> {
203-
let provider_results: Vec<_> = self
204-
.results
205-
.iter()
206-
.filter(|r| r.provider == provider)
242+
let mut unhealthy: Vec<String> = providers
243+
.into_iter()
244+
.filter(|(_, (total, healthy))| *total > 0 && *healthy == 0)
245+
.map(|(name, _)| name)
207246
.collect();
208247

209-
if provider_results.is_empty() {
210-
return None;
248+
// Also check circuit breakers for providers not in probe results
249+
let mut cb_providers: HashMap<String, (usize, usize)> = HashMap::new();
250+
for (key, breaker) in &self.breakers {
251+
if let Some(provider) = key.split(':').next() {
252+
if !unhealthy.contains(&provider.to_string()) {
253+
let entry = cb_providers.entry(provider.to_string()).or_insert((0, 0));
254+
entry.0 += 1;
255+
if breaker.should_allow() {
256+
entry.1 += 1;
257+
}
258+
}
259+
}
211260
}
212261

213-
// If ANY model for this provider succeeded, provider is healthy
214-
if provider_results
215-
.iter()
216-
.any(|r| r.status == ProbeStatus::Success)
217-
{
218-
Some(ProbeStatus::Success)
219-
} else {
220-
// All models failed -- use the "least bad" status
221-
Some(provider_results[0].status)
262+
for (name, (total, healthy)) in cb_providers {
263+
if total > 0 && healthy == 0 && !unhealthy.contains(&name) {
264+
unhealthy.push(name);
265+
}
222266
}
267+
268+
unhealthy
223269
}
224270

225271
/// Record a success for a provider (e.g., from ExitClassifier).
272+
/// Record a success for a provider+model (e.g., from ExitClassifier).
226273
pub fn record_success(&mut self, provider: &str) {
227-
if let Some(breaker) = self.breakers.get_mut(provider) {
274+
// Update all circuit breakers for this provider
275+
let prefix = format!("{provider}:");
276+
for (key, breaker) in &mut self.breakers {
277+
if key.starts_with(&prefix) {
278+
breaker.record_success();
279+
}
280+
}
281+
}
282+
283+
/// Record a success for a specific model.
284+
pub fn record_model_success(&mut self, provider: &str, model: &str) {
285+
let key = format!("{provider}:{model}");
286+
if let Some(breaker) = self.breakers.get_mut(&key) {
228287
breaker.record_success();
229288
}
230289
}
231290

232-
/// Record a failure for a provider (e.g., from ExitClassifier ModelError).
291+
/// Record a failure for a provider (affects all models for that provider).
233292
pub fn record_failure(&mut self, provider: &str) {
293+
let prefix = format!("{provider}:");
294+
let keys: Vec<String> = self
295+
.breakers
296+
.keys()
297+
.filter(|k| k.starts_with(&prefix))
298+
.cloned()
299+
.collect();
300+
301+
for key in keys {
302+
let breaker = self.breakers.get_mut(&key).unwrap();
303+
breaker.record_failure();
304+
}
305+
306+
// If no breakers exist for this provider, create one at provider level
307+
if !self.breakers.keys().any(|k| k.starts_with(&prefix)) {
308+
let key = format!("{provider}:*");
309+
let breaker = self
310+
.breakers
311+
.entry(key)
312+
.or_insert_with(|| CircuitBreaker::new(self.cb_config.clone()));
313+
breaker.record_failure();
314+
}
315+
316+
warn!(
317+
provider = provider,
318+
"provider failure recorded (all models)"
319+
);
320+
}
321+
322+
/// Record a failure for a specific model.
323+
pub fn record_model_failure(&mut self, provider: &str, model: &str) {
324+
let key = format!("{provider}:{model}");
234325
let breaker = self
235326
.breakers
236-
.entry(provider.to_string())
327+
.entry(key)
237328
.or_insert_with(|| CircuitBreaker::new(self.cb_config.clone()));
238329
breaker.record_failure();
239330
warn!(
240331
provider = provider,
332+
model = model,
241333
state = %breaker.state(),
242-
"provider failure recorded"
334+
"model failure recorded"
243335
);
244336
}
245337

@@ -489,5 +581,41 @@ mod tests {
489581
];
490582
// One model succeeded -> provider is healthy
491583
assert!(map.is_healthy("minimax"));
584+
// But the timed-out model is individually unhealthy
585+
assert!(!map.is_model_healthy("minimax", "opencode-go/minimax-m2.5"));
586+
assert!(map.is_model_healthy("minimax", "minimax-coding-plan/MiniMax-M2.5"));
587+
}
588+
589+
#[test]
590+
fn per_model_failure_does_not_affect_other_models() {
591+
let mut map = ProviderHealthMap::new(Duration::from_secs(300));
592+
// Opus fails but sonnet works
593+
map.results = vec![
594+
ProbeResult {
595+
provider: "anthropic".to_string(),
596+
model: "claude-opus-4-6".to_string(),
597+
cli_tool: "claude".to_string(),
598+
status: ProbeStatus::Error,
599+
latency_ms: Some(5000),
600+
error: Some("rate limited".to_string()),
601+
timestamp: String::new(),
602+
},
603+
ProbeResult {
604+
provider: "anthropic".to_string(),
605+
model: "claude-sonnet-4-6".to_string(),
606+
cli_tool: "claude".to_string(),
607+
status: ProbeStatus::Success,
608+
latency_ms: Some(8000),
609+
error: None,
610+
timestamp: String::new(),
611+
},
612+
];
613+
// Provider healthy (sonnet works)
614+
assert!(map.is_healthy("anthropic"));
615+
// But opus individually unhealthy
616+
assert!(!map.is_model_healthy("anthropic", "claude-opus-4-6"));
617+
assert!(map.is_model_healthy("anthropic", "claude-sonnet-4-6"));
618+
// Provider NOT in unhealthy list (sonnet keeps it alive)
619+
assert!(!map.unhealthy_providers().contains(&"anthropic".to_string()));
492620
}
493621
}

0 commit comments

Comments
 (0)