diff --git a/Cargo.lock b/Cargo.lock index 60ebafbf..b22d6d67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,7 +515,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-sub-agent" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -704,7 +704,7 @@ dependencies = [ [[package]] name = "csa-acp" -version = "0.1.200" +version = "0.1.201" dependencies = [ "agent-client-protocol", "anyhow", @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "csa-config" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "csa-core" -version = "0.1.200" +version = "0.1.201" dependencies = [ "agent-teams", "chrono", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "csa-eval" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "csa-executor" -version = "0.1.200" +version = "0.1.201" dependencies = [ "agent-teams", "anyhow", @@ -795,7 +795,7 @@ dependencies = [ [[package]] name = "csa-hooks" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "csa-lock" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -824,7 +824,7 @@ dependencies = [ [[package]] name = "csa-mcp-hub" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "axum", @@ -846,7 +846,7 @@ dependencies = [ [[package]] name = "csa-memory" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "async-trait", @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "csa-process" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -882,7 +882,7 @@ dependencies = [ [[package]] name = "csa-resource" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "csa-core", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "csa-scheduler" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -916,7 +916,7 @@ dependencies = [ [[package]] name = "csa-session" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -937,7 +937,7 @@ dependencies = [ [[package]] name = "csa-todo" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "chrono", @@ -4367,7 +4367,7 @@ dependencies = [ [[package]] name = "weave" -version = "0.1.200" +version = "0.1.201" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index e79e494a..241faae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.200" +version = "0.1.201" edition = "2024" rust-version = "1.88" license = "Apache-2.0" diff --git a/crates/csa-executor/src/transport.rs b/crates/csa-executor/src/transport.rs index 354018d7..a9ef6c2e 100644 --- a/crates/csa-executor/src/transport.rs +++ b/crates/csa-executor/src/transport.rs @@ -4,9 +4,9 @@ use std::time::Duration; use crate::executor::Executor; use crate::transport_gemini_retry::{ - gemini_inject_api_key_fallback, gemini_max_attempts, gemini_rate_limit_backoff, - gemini_retry_model, gemini_should_use_api_key, is_gemini_rate_limited_error, - is_gemini_rate_limited_result, + gemini_auth_mode, gemini_inject_api_key_fallback, gemini_max_attempts, + gemini_rate_limit_backoff, gemini_retry_model, gemini_should_use_api_key, + is_gemini_rate_limited_error, is_gemini_rate_limited_result, }; use anyhow::{Result, anyhow}; use async_trait::async_trait; @@ -238,13 +238,35 @@ impl LegacyTransport { idle_timeout_seconds: u64, ) -> Result { // 3-phase fallback: OAuth(original) → APIKey(original) → APIKey(flash) + let has_fallback_key = extra_env + .is_some_and(|env| env.contains_key(csa_core::gemini::API_KEY_FALLBACK_ENV_KEY)); + let auth_mode = gemini_auth_mode(extra_env).unwrap_or("unknown"); + let max_attempts = gemini_max_attempts(extra_env); + tracing::debug!( + max_attempts, + has_fallback_key, + auth_mode, + "gemini-cli legacy retry chain initialized" + ); + let mut attempt = 1u8; loop { let executor = self.executor_for_attempt(attempt); // Phase 2+: inject API key auth if available, otherwise keep original env. let api_key_env = if gemini_should_use_api_key(attempt) { - gemini_inject_api_key_fallback(extra_env) + let injected = gemini_inject_api_key_fallback(extra_env); + if injected.is_none() { + tracing::warn!( + attempt, + auth_mode, + has_fallback_key, + "gemini-cli legacy: API key fallback unavailable for retry \ + (auth_mode must be 'oauth' and _CSA_API_KEY_FALLBACK must be set); \ + retrying with original auth" + ); + } + injected } else { None }; @@ -576,17 +598,39 @@ impl Transport for AcpTransport { // Gemini-cli: 3-phase fallback: OAuth(original) → APIKey(original) → APIKey(flash) let max_attempts = gemini_max_attempts(extra_env); + let has_fallback_key = extra_env + .is_some_and(|env| env.contains_key(csa_core::gemini::API_KEY_FALLBACK_ENV_KEY)); + let auth_mode = gemini_auth_mode(extra_env).unwrap_or("unknown"); + tracing::debug!( + max_attempts, + has_fallback_key, + auth_mode, + "gemini-cli ACP retry chain initialized" + ); + let mut attempt = 1u8; loop { // Build ACP args for this attempt, injecting model override in phase 3. let mut args = self.acp_args.clone(); if let Some(model) = gemini_retry_model(attempt) { + tracing::info!(attempt, model, "gemini-cli ACP: overriding model for retry"); args.extend(["-m".into(), model.into()]); } // Phase 2+: inject API key auth if available, otherwise keep original env. let api_key_env = if gemini_should_use_api_key(attempt) { - gemini_inject_api_key_fallback(extra_env) + let injected = gemini_inject_api_key_fallback(extra_env); + if injected.is_none() { + tracing::warn!( + attempt, + auth_mode, + has_fallback_key, + "gemini-cli ACP: API key fallback unavailable for retry \ + (auth_mode must be 'oauth' and _CSA_API_KEY_FALLBACK must be set); \ + retrying with original auth" + ); + } + injected } else { None }; @@ -603,6 +647,13 @@ impl Transport for AcpTransport { tracing::debug!(%session_id, "resuming ACP session from tool state"); } + tracing::debug!( + attempt, + max_attempts, + has_api_key_override = api_key_env.is_some(), + "gemini-cli ACP: executing attempt" + ); + let result = self .execute_acp_attempt( prompt, @@ -635,6 +686,14 @@ impl Transport for AcpTransport { continue; } + if should_retry { + tracing::warn!( + attempt, + max_attempts, + "gemini-cli ACP: all retry phases exhausted, returning last result" + ); + } + return result; } } @@ -725,6 +784,7 @@ mod tests { use crate::transport_gemini_retry::*; include!("transport_tests_tail.rs"); + include!("transport_tests_gemini_fallback.rs"); include!("transport_tests_extra.rs"); } diff --git a/crates/csa-executor/src/transport_tests_gemini_fallback.rs b/crates/csa-executor/src/transport_tests_gemini_fallback.rs new file mode 100644 index 00000000..d0b02c7d --- /dev/null +++ b/crates/csa-executor/src/transport_tests_gemini_fallback.rs @@ -0,0 +1,291 @@ +#[test] +fn test_gemini_should_use_api_key_by_phase() { + // Phase 1: OAuth auth + assert!(!gemini_should_use_api_key(1)); + // Phase 2: API key auth (same model) + assert!(gemini_should_use_api_key(2)); + // Phase 3: API key auth (flash model) + assert!(gemini_should_use_api_key(3)); +} + +#[test] +fn test_gemini_rate_limit_backoff_is_exponential() { + assert_eq!( + gemini_rate_limit_backoff(1), + Duration::from_millis(GEMINI_RATE_LIMIT_BASE_BACKOFF_MS) + ); + assert_eq!( + gemini_rate_limit_backoff(2), + Duration::from_millis(GEMINI_RATE_LIMIT_BASE_BACKOFF_MS * 2) + ); +} + +#[test] +fn test_inject_api_key_fallback_promotes_key_and_removes_internal() { + let mut env = HashMap::new(); + env.insert("_CSA_API_KEY_FALLBACK".to_string(), "test-api-key-123".to_string()); + env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "oauth".to_string()); + env.insert("OTHER_VAR".to_string(), "keep".to_string()); + let result = gemini_inject_api_key_fallback(Some(&env)).unwrap(); + assert_eq!(result.get("GEMINI_API_KEY").unwrap(), "test-api-key-123"); + assert_eq!(result.get("_CSA_GEMINI_AUTH_MODE").unwrap(), "api_key"); + assert!(!result.contains_key("_CSA_API_KEY_FALLBACK")); + assert_eq!(result.get("OTHER_VAR").unwrap(), "keep"); +} + +#[test] +fn test_inject_api_key_fallback_returns_none_without_key() { + let env = HashMap::new(); + assert!(gemini_inject_api_key_fallback(Some(&env)).is_none()); + assert!(gemini_inject_api_key_fallback(None).is_none()); +} + +#[test] +fn test_inject_api_key_fallback_returns_none_for_api_key_mode() { + let mut env = HashMap::new(); + env.insert("_CSA_API_KEY_FALLBACK".to_string(), "fallback-key".to_string()); + env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "api_key".to_string()); + assert!(gemini_inject_api_key_fallback(Some(&env)).is_none()); +} + +#[tokio::test] +async fn test_execute_in_falls_back_to_api_key_after_all_retries_exhausted() { + let (_temp, mut env, _model_log_path) = setup_fake_gemini_environment(99); + env.insert("_CSA_API_KEY_FALLBACK".to_string(), "fallback-key".to_string()); + env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "oauth".to_string()); + let transport = LegacyTransport::new(Executor::GeminiCli { + model_override: None, + thinking_budget: None, + }); + + let result = transport + .execute_in( + "test api key fallback", + std::path::Path::new("/tmp"), + Some(&env), + StreamMode::BufferOnly, + 30, + ) + .await + .expect("execute_in should succeed with api key fallback"); + + // The fake script always fails with QUOTA_EXHAUSTED; the fallback attempt + // also uses the same fake script (which increments the counter). After 3 + // model-retry attempts + 1 fallback attempt = 4 total. The fallback attempt + // still fails because success_on=99, but we verify the fallback path was taken + // by checking GEMINI_API_KEY was injected (the env var will be visible to the script). + // Since the fake script doesn't check GEMINI_API_KEY, just verify the result came back. + assert_ne!(result.execution.exit_code, 0); + assert!(result.execution.stderr_output.contains("QUOTA_EXHAUSTED")); +} + +#[tokio::test] +async fn test_execute_falls_back_to_api_key_after_all_retries_exhausted() { + let (temp, mut env, _model_log_path) = setup_fake_gemini_environment(99); + env.insert("_CSA_API_KEY_FALLBACK".to_string(), "fallback-key".to_string()); + env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "oauth".to_string()); + let transport = LegacyTransport::new(Executor::GeminiCli { + model_override: None, + thinking_budget: None, + }); + let session = build_test_meta_session(temp.path().to_str().expect("utf8 temp path")); + let options = TransportOptions { + stream_mode: StreamMode::BufferOnly, + idle_timeout_seconds: 30, + initial_response_timeout_seconds: None, + liveness_dead_seconds: 30, + stdin_write_timeout_seconds: 30, + acp_init_timeout_seconds: 30, + termination_grace_period_seconds: 1, + output_spool: None, + output_spool_max_bytes: csa_process::DEFAULT_SPOOL_MAX_BYTES, + output_spool_keep_rotated: csa_process::DEFAULT_SPOOL_KEEP_ROTATED, + setting_sources: None, + sandbox: None, + }; + + let result = transport + .execute("test api key fallback", None, &session, Some(&env), options) + .await + .expect("execute should complete with api key fallback attempt"); + + // Fallback attempt still fails (success_on=99), but 4 total attempts + // (3 model retries + 1 fallback) confirms the fallback path was taken. + assert_ne!(result.execution.exit_code, 0); + assert!(result.execution.stderr_output.contains("QUOTA_EXHAUSTED")); +} + +#[tokio::test] +async fn test_execute_best_effort_sandbox_fallback_preserves_attempt_model_override() { + if !matches!( + csa_resource::sandbox::detect_resource_capability(), + csa_resource::sandbox::ResourceCapability::CgroupV2 + ) { + // This test specifically targets the cgroup sandbox spawn failure -> + // best-effort unsandboxed fallback branch. + return; + } + + let (temp, mut env, model_log_path) = setup_fake_gemini_environment(2); + // Force sandbox spawn failure by hiding systemd-run from PATH while keeping + // our fake gemini binary and basic shell tools available. + env.insert( + "PATH".to_string(), + format!("{}:/bin", temp.path().display()), + ); + + let transport = LegacyTransport::new(Executor::GeminiCli { + model_override: None, + thinking_budget: None, + }); + let session = build_test_meta_session(temp.path().to_str().expect("utf8 temp path")); + let sandbox = SandboxTransportConfig { + isolation_plan: csa_resource::isolation_plan::IsolationPlan { + resource: csa_resource::sandbox::ResourceCapability::None, + filesystem: csa_resource::filesystem_sandbox::FilesystemCapability::None, + writable_paths: Vec::new(), + env_overrides: std::collections::HashMap::new(), + degraded_reasons: Vec::new(), + memory_max_mb: None, + memory_swap_max_mb: None, + pids_max: None, + readonly_project_root: false, + project_root: None, + }, + tool_name: "gemini-cli".to_string(), + best_effort: true, + session_id: "01HTESTBESTEFFORT0000000001".to_string(), + }; + let options = TransportOptions { + stream_mode: StreamMode::BufferOnly, + idle_timeout_seconds: 30, + initial_response_timeout_seconds: None, + liveness_dead_seconds: 30, + stdin_write_timeout_seconds: 30, + acp_init_timeout_seconds: 30, + termination_grace_period_seconds: 1, + output_spool: None, + output_spool_max_bytes: csa_process::DEFAULT_SPOOL_MAX_BYTES, + output_spool_keep_rotated: csa_process::DEFAULT_SPOOL_KEEP_ROTATED, + setting_sources: None, + sandbox: Some(&sandbox), + }; + + let result = transport + .execute("test best effort fallback", None, &session, Some(&env), options) + .await + .expect("execute should succeed after best-effort fallback and retry"); + + assert_eq!(result.execution.exit_code, 0); + let models = read_model_log(&model_log_path); + assert_eq!( + models, + vec!["inherit".to_string(), "inherit".to_string()], + "best-effort fallback path: phase 2 keeps original model (switches to API key auth)" + ); +} + +#[test] +fn test_is_gemini_rate_limited_error_matches_acp_wrapped_capacity_error() { + // This mirrors the real error chain from ACP transport: + // anyhow!("ACP transport (sandboxed) failed: {e}") where e is AcpError::PromptFailed + let acp_error_msg = "ACP transport (sandboxed) failed: ACP prompt failed: \ + No capacity available for model gemini-3.1-pro-preview on the server; \ + stderr: Running scope as unit: csa-gemini-cli-01KN.scope"; + assert!( + is_gemini_rate_limited_error(acp_error_msg), + "should detect 'no capacity available' inside ACP-wrapped error" + ); +} + +#[test] +fn test_is_gemini_rate_limited_error_matches_acp_wrapped_429_error() { + let acp_error_msg = + "ACP transport (sandboxed) failed: ACP prompt failed: 429 Too Many Requests"; + assert!( + is_gemini_rate_limited_error(acp_error_msg), + "should detect '429' inside ACP-wrapped error" + ); +} + +#[test] +fn test_is_gemini_rate_limited_error_matches_acp_wrapped_quota_exhausted() { + let acp_error_msg = + "ACP transport (sandboxed) failed: ACP prompt failed: quota exhausted for project"; + assert!( + is_gemini_rate_limited_error(acp_error_msg), + "should detect 'quota exhausted' inside ACP-wrapped error" + ); +} + +#[test] +fn test_is_gemini_rate_limited_error_matches_unsandboxed_fallback_error() { + let acp_error_msg = + "ACP transport (unsandboxed fallback) failed: ACP prompt failed: resource exhausted"; + assert!( + is_gemini_rate_limited_error(acp_error_msg), + "should detect 'resource exhausted' in unsandboxed fallback path" + ); +} + +#[test] +fn test_is_gemini_rate_limited_error_matches_plain_acp_error() { + let acp_error_msg = + "ACP transport failed: ACP prompt failed: No capacity available for model"; + assert!( + is_gemini_rate_limited_error(acp_error_msg), + "should detect rate limit in non-sandboxed ACP path" + ); +} + +#[test] +fn test_is_gemini_rate_limited_error_rejects_non_rate_limit_acp_error() { + let acp_error_msg = + "ACP transport (sandboxed) failed: ACP prompt failed: internal server error"; + assert!( + !is_gemini_rate_limited_error(acp_error_msg), + "should not match non-rate-limit errors" + ); +} + +#[test] +fn test_is_gemini_rate_limited_result_matches_capacity_in_stdout() { + let execution = csa_process::ExecutionResult { + summary: String::new(), + output: "No capacity available for model gemini-3.1-pro-preview".to_string(), + stderr_output: String::new(), + exit_code: 1, + }; + assert!( + is_gemini_rate_limited_result(&execution), + "should detect rate limit pattern in stdout" + ); +} + +#[test] +fn test_is_gemini_rate_limited_result_matches_capacity_in_stderr() { + let execution = csa_process::ExecutionResult { + summary: String::new(), + output: String::new(), + stderr_output: "No capacity available for model gemini-3.1-pro-preview".to_string(), + exit_code: 1, + }; + assert!( + is_gemini_rate_limited_result(&execution), + "should detect rate limit pattern in stderr" + ); +} + +#[test] +fn test_is_gemini_rate_limited_result_ignores_success_exit_code() { + let execution = csa_process::ExecutionResult { + summary: String::new(), + output: "No capacity available for model".to_string(), + stderr_output: String::new(), + exit_code: 0, + }; + assert!( + !is_gemini_rate_limited_result(&execution), + "should not retry when exit code is 0 even if output contains rate limit text" + ); +} diff --git a/crates/csa-executor/src/transport_tests_tail.rs b/crates/csa-executor/src/transport_tests_tail.rs index 506ee2fa..774d651f 100644 --- a/crates/csa-executor/src/transport_tests_tail.rs +++ b/crates/csa-executor/src/transport_tests_tail.rs @@ -589,190 +589,3 @@ fn test_no_flash_fallback_stops_retry_after_attempt_2() { // Without the flag, attempt 2 would still retry (advances to phase 3: flash) assert!(transport.should_retry_gemini_rate_limited(&execution, 2, None).is_some()); } - -#[test] -fn test_gemini_should_use_api_key_by_phase() { - // Phase 1: OAuth auth - assert!(!gemini_should_use_api_key(1)); - // Phase 2: API key auth (same model) - assert!(gemini_should_use_api_key(2)); - // Phase 3: API key auth (flash model) - assert!(gemini_should_use_api_key(3)); -} - -#[test] -fn test_gemini_rate_limit_backoff_is_exponential() { - assert_eq!( - gemini_rate_limit_backoff(1), - Duration::from_millis(GEMINI_RATE_LIMIT_BASE_BACKOFF_MS) - ); - assert_eq!( - gemini_rate_limit_backoff(2), - Duration::from_millis(GEMINI_RATE_LIMIT_BASE_BACKOFF_MS * 2) - ); -} - -#[test] -fn test_inject_api_key_fallback_promotes_key_and_removes_internal() { - let mut env = HashMap::new(); - env.insert("_CSA_API_KEY_FALLBACK".to_string(), "test-api-key-123".to_string()); - env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "oauth".to_string()); - env.insert("OTHER_VAR".to_string(), "keep".to_string()); - let result = gemini_inject_api_key_fallback(Some(&env)).unwrap(); - assert_eq!(result.get("GEMINI_API_KEY").unwrap(), "test-api-key-123"); - assert_eq!(result.get("_CSA_GEMINI_AUTH_MODE").unwrap(), "api_key"); - assert!(!result.contains_key("_CSA_API_KEY_FALLBACK")); - assert_eq!(result.get("OTHER_VAR").unwrap(), "keep"); -} - -#[test] -fn test_inject_api_key_fallback_returns_none_without_key() { - let env = HashMap::new(); - assert!(gemini_inject_api_key_fallback(Some(&env)).is_none()); - assert!(gemini_inject_api_key_fallback(None).is_none()); -} - -#[test] -fn test_inject_api_key_fallback_returns_none_for_api_key_mode() { - let mut env = HashMap::new(); - env.insert("_CSA_API_KEY_FALLBACK".to_string(), "fallback-key".to_string()); - env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "api_key".to_string()); - assert!(gemini_inject_api_key_fallback(Some(&env)).is_none()); -} - -#[tokio::test] -async fn test_execute_in_falls_back_to_api_key_after_all_retries_exhausted() { - let (_temp, mut env, _model_log_path) = setup_fake_gemini_environment(99); - env.insert("_CSA_API_KEY_FALLBACK".to_string(), "fallback-key".to_string()); - env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "oauth".to_string()); - let transport = LegacyTransport::new(Executor::GeminiCli { - model_override: None, - thinking_budget: None, - }); - - let result = transport - .execute_in( - "test api key fallback", - std::path::Path::new("/tmp"), - Some(&env), - StreamMode::BufferOnly, - 30, - ) - .await - .expect("execute_in should succeed with api key fallback"); - - // The fake script always fails with QUOTA_EXHAUSTED; the fallback attempt - // also uses the same fake script (which increments the counter). After 3 - // model-retry attempts + 1 fallback attempt = 4 total. The fallback attempt - // still fails because success_on=99, but we verify the fallback path was taken - // by checking GEMINI_API_KEY was injected (the env var will be visible to the script). - // Since the fake script doesn't check GEMINI_API_KEY, just verify the result came back. - assert_ne!(result.execution.exit_code, 0); - assert!(result.execution.stderr_output.contains("QUOTA_EXHAUSTED")); -} - -#[tokio::test] -async fn test_execute_falls_back_to_api_key_after_all_retries_exhausted() { - let (temp, mut env, _model_log_path) = setup_fake_gemini_environment(99); - env.insert("_CSA_API_KEY_FALLBACK".to_string(), "fallback-key".to_string()); - env.insert("_CSA_GEMINI_AUTH_MODE".to_string(), "oauth".to_string()); - let transport = LegacyTransport::new(Executor::GeminiCli { - model_override: None, - thinking_budget: None, - }); - let session = build_test_meta_session(temp.path().to_str().expect("utf8 temp path")); - let options = TransportOptions { - stream_mode: StreamMode::BufferOnly, - idle_timeout_seconds: 30, - initial_response_timeout_seconds: None, - liveness_dead_seconds: 30, - stdin_write_timeout_seconds: 30, - acp_init_timeout_seconds: 30, - termination_grace_period_seconds: 1, - output_spool: None, - output_spool_max_bytes: csa_process::DEFAULT_SPOOL_MAX_BYTES, - output_spool_keep_rotated: csa_process::DEFAULT_SPOOL_KEEP_ROTATED, - setting_sources: None, - sandbox: None, - }; - - let result = transport - .execute("test api key fallback", None, &session, Some(&env), options) - .await - .expect("execute should complete with api key fallback attempt"); - - // Fallback attempt still fails (success_on=99), but 4 total attempts - // (3 model retries + 1 fallback) confirms the fallback path was taken. - assert_ne!(result.execution.exit_code, 0); - assert!(result.execution.stderr_output.contains("QUOTA_EXHAUSTED")); -} - -#[tokio::test] -async fn test_execute_best_effort_sandbox_fallback_preserves_attempt_model_override() { - if !matches!( - csa_resource::sandbox::detect_resource_capability(), - csa_resource::sandbox::ResourceCapability::CgroupV2 - ) { - // This test specifically targets the cgroup sandbox spawn failure -> - // best-effort unsandboxed fallback branch. - return; - } - - let (temp, mut env, model_log_path) = setup_fake_gemini_environment(2); - // Force sandbox spawn failure by hiding systemd-run from PATH while keeping - // our fake gemini binary and basic shell tools available. - env.insert( - "PATH".to_string(), - format!("{}:/bin", temp.path().display()), - ); - - let transport = LegacyTransport::new(Executor::GeminiCli { - model_override: None, - thinking_budget: None, - }); - let session = build_test_meta_session(temp.path().to_str().expect("utf8 temp path")); - let sandbox = SandboxTransportConfig { - isolation_plan: csa_resource::isolation_plan::IsolationPlan { - resource: csa_resource::sandbox::ResourceCapability::None, - filesystem: csa_resource::filesystem_sandbox::FilesystemCapability::None, - writable_paths: Vec::new(), - env_overrides: std::collections::HashMap::new(), - degraded_reasons: Vec::new(), - memory_max_mb: None, - memory_swap_max_mb: None, - pids_max: None, - readonly_project_root: false, - project_root: None, - }, - tool_name: "gemini-cli".to_string(), - best_effort: true, - session_id: "01HTESTBESTEFFORT0000000001".to_string(), - }; - let options = TransportOptions { - stream_mode: StreamMode::BufferOnly, - idle_timeout_seconds: 30, - initial_response_timeout_seconds: None, - liveness_dead_seconds: 30, - stdin_write_timeout_seconds: 30, - acp_init_timeout_seconds: 30, - termination_grace_period_seconds: 1, - output_spool: None, - output_spool_max_bytes: csa_process::DEFAULT_SPOOL_MAX_BYTES, - output_spool_keep_rotated: csa_process::DEFAULT_SPOOL_KEEP_ROTATED, - setting_sources: None, - sandbox: Some(&sandbox), - }; - - let result = transport - .execute("test best effort fallback", None, &session, Some(&env), options) - .await - .expect("execute should succeed after best-effort fallback and retry"); - - assert_eq!(result.execution.exit_code, 0); - let models = read_model_log(&model_log_path); - assert_eq!( - models, - vec!["inherit".to_string(), "inherit".to_string()], - "best-effort fallback path: phase 2 keeps original model (switches to API key auth)" - ); -} diff --git a/weave.lock b/weave.lock index 523c9a45..9eb3ec93 100644 --- a/weave.lock +++ b/weave.lock @@ -1,9 +1,9 @@ package = [] [versions] -csa = "0.1.199" +csa = "0.1.200" last_migrated_at = "2026-03-08T12:08:01.820964091Z" -weave = "0.1.199" +weave = "0.1.200" [migrations] applied = [