diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0eb79f88ea..9487a9b650 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -513,6 +513,8 @@ pub(crate) struct TurnContext { pub(crate) turn_metadata_header: Option, } +const TURN_METADATA_TIMEOUT: TokioDuration = TokioDuration::from_millis(150); + impl TurnContext { pub(crate) fn resolve_path(&self, path: Option) -> PathBuf { path.as_ref() @@ -734,7 +736,7 @@ impl Session { // On some CI environments (notably Windows), git commands can hang // until their timeout elapses. Cap the total time we spend gathering // optional metadata so turn startup stays responsive. - timeout(TokioDuration::from_millis(750), async { + timeout(TURN_METADATA_TIMEOUT, async { let git_info = collect_git_info(cwd).await?; let latest_git_commit_hash = git_info.commit_hash; let associated_remote_urls = get_git_remote_urls(cwd).await; @@ -757,6 +759,20 @@ impl Session { .flatten() } + async fn get_or_build_turn_metadata_header(&self, cwd: &Path) -> Option { + if let Some(cached) = { + let state = self.state.lock().await; + state.cached_turn_metadata_header(cwd) + } { + return cached; + } + + let header = Self::build_turn_metadata_header(cwd).await; + let mut state = self.state.lock().await; + state.set_turn_metadata_header_cache(cwd, header.clone()); + header + } + #[allow(clippy::too_many_arguments)] async fn new( mut session_configuration: SessionConfiguration, @@ -1293,8 +1309,9 @@ impl Session { if let Some(final_schema) = final_output_json_schema { turn_context.final_output_json_schema = final_schema; } - turn_context.turn_metadata_header = - Self::build_turn_metadata_header(turn_context.cwd.as_path()).await; + turn_context.turn_metadata_header = self + .get_or_build_turn_metadata_header(turn_context.cwd.as_path()) + .await; Arc::new(turn_context) } @@ -3250,8 +3267,9 @@ async fn spawn_review_thread( parent_turn_context.client.transport_manager(), ); - let turn_metadata_header = - Session::build_turn_metadata_header(parent_turn_context.cwd.as_path()).await; + let turn_metadata_header = sess + .get_or_build_turn_metadata_header(parent_turn_context.cwd.as_path()) + .await; let review_turn_context = TurnContext { sub_id: sub_id.to_string(), client, diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index e6e128399b..fdca45b1e4 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -128,11 +128,19 @@ pub async fn get_git_remote_urls(cwd: &Path) -> Option> let stdout = String::from_utf8(output.stdout).ok()?; let mut remotes = BTreeMap::new(); for line in stdout.lines() { - if !line.contains("(fetch)") { + let Some(fetch_line) = line.strip_suffix(" (fetch)") else { continue; - } - let mut parts = line.split_whitespace(); - if let (Some(name), Some(url)) = (parts.next(), parts.next()) { + }; + + let Some((name, url_part)) = fetch_line + .split_once('\t') + .or_else(|| fetch_line.split_once(' ')) + else { + continue; + }; + + let url = url_part.trim_start(); + if !url.is_empty() { remotes.insert(name.to_string(), url.to_string()); } } @@ -220,11 +228,10 @@ pub async fn git_diff_to_remote(cwd: &Path) -> Option { /// Run a git command with a timeout to prevent blocking on large repositories async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option { - let result = timeout( - GIT_COMMAND_TIMEOUT, - Command::new("git").args(args).current_dir(cwd).output(), - ) - .await; + let mut command = Command::new("git"); + command.args(args).current_dir(cwd); + command.kill_on_drop(true); + let result = timeout(GIT_COMMAND_TIMEOUT, command.output()).await; match result { Ok(Ok(output)) => Some(output), diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index deee0d0c7c..479f44f562 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,6 +3,7 @@ use codex_protocol::models::ResponseItem; use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; use crate::codex::SessionConfiguration; use crate::context_manager::ContextManager; @@ -19,6 +20,7 @@ pub(crate) struct SessionState { pub(crate) server_reasoning_included: bool, pub(crate) dependency_env: HashMap, pub(crate) mcp_dependency_prompted: HashSet, + pub(crate) turn_metadata_header_cache: HashMap>, /// Whether the session's initial context has been seeded into history. /// /// TODO(owen): This is a temporary solution to avoid updating a thread's updated_at @@ -37,6 +39,7 @@ impl SessionState { server_reasoning_included: false, dependency_env: HashMap::new(), mcp_dependency_prompted: HashSet::new(), + turn_metadata_header_cache: HashMap::new(), initial_context_seeded: false, } } @@ -125,6 +128,16 @@ impl SessionState { pub(crate) fn dependency_env(&self) -> HashMap { self.dependency_env.clone() } + + pub(crate) fn cached_turn_metadata_header(&self, cwd: &Path) -> Option> { + let key = cwd.to_string_lossy().into_owned(); + self.turn_metadata_header_cache.get(&key).cloned() + } + + pub(crate) fn set_turn_metadata_header_cache(&mut self, cwd: &Path, header: Option) { + let key = cwd.to_string_lossy().into_owned(); + self.turn_metadata_header_cache.insert(key, header); + } } // Sometimes new snapshots don't include credits or plan information.