mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
b389b950e1
## Summary Turn diff updates repeatedly rendered and serialized the entire accumulated diff after every `apply_patch`. The event path also rendered once before updating the tracker solely to test whether a diff existed. In production feedback CODEX-20PW, 2,589 patches across 72 paths produced 401 notifications totaling 441 MB, with the hottest paths patched 518 and 495 times. This change: - replaces the pre-update render with a cheap cached-state check - caches each rendered file diff by path and content revision, so an update only invokes Myers for affected paths - caches the deterministic aggregate diff so event emission and turn completion reuse it without recomputation - preserves invalidation and net-zero clear notifications - applies a 100 ms per-file `similar` timeout; ordinary files complete far below this threshold, while pathological rewrites fall back to a coarse unified hunk that still represents the exact final contents The 100 ms deadline bounds synchronous tool-completion latency while leaving substantial headroom for normal diffs. The regression test applies the fallback diff through the repository's patch parser and verifies byte-for-byte final contents. ## Validation - `cargo test -p codex-core turn_diff_tracker::tests` (14 passed) - `cargo test -p codex-core tools::events::tests` (4 passed) - `just fix -p codex-core` - `just fmt` Focused coverage verifies that 42 updates across two files perform 42 file renders rather than repeatedly rendering the accumulated set, unchanged paths are not re-diffed, clear events remain correct, and a 48,000-line near-total rewrite returns promptly and applies to the exact expected result. The full `codex-core` suite was not used as the final gate because an unrelated existing multi-agent test hit a stack overflow when run during investigation. ## Bug context - Sentry feedback: CODEX-20PW - Correlation IDs: `019eb2a9-13d2-74e0-b690-27ee224ffb6d`, `019e9ad7-09c3-7cb2-b728-ee3acba103ab`
405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use sha1::digest::Output;
|
|
|
|
use codex_apply_patch::AppliedPatchChange;
|
|
use codex_apply_patch::AppliedPatchDelta;
|
|
use codex_apply_patch::AppliedPatchFileChange;
|
|
|
|
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
|
|
const DEV_NULL: &str = "/dev/null";
|
|
const REGULAR_FILE_MODE: &str = "100644";
|
|
// Normal edits finish well within 100 ms; pathological inputs fall back to a coarse,
|
|
// content-exact diff without stalling tool completion.
|
|
const DIFF_TIMEOUT: Duration = Duration::from_millis(100);
|
|
|
|
struct TrackedContent {
|
|
content: String,
|
|
revision: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
struct TrackedPath {
|
|
environment_id: String,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TrackedPath {
|
|
fn new(environment_id: &str, path: &Path) -> Self {
|
|
Self {
|
|
environment_id: environment_id.to_string(),
|
|
path: path.to_path_buf(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Eq, Hash, PartialEq)]
|
|
struct DiffCacheKey {
|
|
left_path: TrackedPath,
|
|
left_revision: Option<u64>,
|
|
right_path: TrackedPath,
|
|
right_revision: Option<u64>,
|
|
}
|
|
|
|
/// Tracks the net text diff for the current turn from committed apply_patch
|
|
/// mutations, without rereading the workspace filesystem.
|
|
pub struct TurnDiffTracker {
|
|
valid: bool,
|
|
display_roots_by_environment: HashMap<String, PathBuf>,
|
|
baseline_by_path: HashMap<TrackedPath, TrackedContent>,
|
|
current_by_path: HashMap<TrackedPath, TrackedContent>,
|
|
origin_by_current_path: HashMap<TrackedPath, TrackedPath>,
|
|
next_revision: u64,
|
|
rendered_diffs: HashMap<DiffCacheKey, Option<String>>,
|
|
unified_diff: Option<String>,
|
|
#[cfg(test)]
|
|
rendered_diff_count: std::cell::Cell<usize>,
|
|
}
|
|
|
|
impl Default for TurnDiffTracker {
|
|
fn default() -> Self {
|
|
Self {
|
|
valid: true,
|
|
display_roots_by_environment: HashMap::new(),
|
|
baseline_by_path: HashMap::new(),
|
|
current_by_path: HashMap::new(),
|
|
origin_by_current_path: HashMap::new(),
|
|
next_revision: 0,
|
|
rendered_diffs: HashMap::new(),
|
|
unified_diff: None,
|
|
#[cfg(test)]
|
|
rendered_diff_count: std::cell::Cell::new(0),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TurnDiffTracker {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_environment_display_roots(
|
|
display_roots: impl IntoIterator<Item = (String, PathBuf)>,
|
|
) -> Self {
|
|
let mut tracker = Self::new();
|
|
tracker.display_roots_by_environment = display_roots.into_iter().collect();
|
|
tracker
|
|
}
|
|
|
|
pub fn track_delta(&mut self, environment_id: &str, delta: &AppliedPatchDelta) {
|
|
if !self.valid {
|
|
return;
|
|
}
|
|
|
|
if !delta.is_exact() {
|
|
self.invalidate();
|
|
return;
|
|
}
|
|
|
|
for change in delta.changes() {
|
|
self.apply_change(environment_id, change);
|
|
}
|
|
self.refresh_unified_diff();
|
|
}
|
|
|
|
pub fn invalidate(&mut self) {
|
|
self.valid = false;
|
|
self.rendered_diffs.clear();
|
|
self.unified_diff = None;
|
|
}
|
|
|
|
pub fn get_unified_diff(&self) -> Option<String> {
|
|
self.unified_diff.clone()
|
|
}
|
|
|
|
pub(crate) fn has_unified_diff(&self) -> bool {
|
|
self.unified_diff.is_some()
|
|
}
|
|
|
|
fn refresh_unified_diff(&mut self) {
|
|
let rename_pairs = self.rename_pairs();
|
|
let paired_destinations = rename_pairs.values().cloned().collect::<HashSet<_>>();
|
|
let mut handled = HashSet::new();
|
|
let mut paths = self
|
|
.baseline_by_path
|
|
.keys()
|
|
.chain(self.current_by_path.keys())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
paths.sort_by_key(|path| self.display_path(path));
|
|
paths.dedup();
|
|
|
|
let mut previous_diffs = std::mem::take(&mut self.rendered_diffs);
|
|
let mut rendered_diffs = HashMap::new();
|
|
let mut aggregated = String::new();
|
|
for path in paths {
|
|
if !handled.insert(path.clone()) {
|
|
continue;
|
|
}
|
|
|
|
if paired_destinations.contains(&path) {
|
|
continue;
|
|
}
|
|
|
|
let (left_path, right_path) = if let Some(dest) = rename_pairs.get(&path) {
|
|
handled.insert(dest.clone());
|
|
(&path, dest)
|
|
} else {
|
|
(&path, &path)
|
|
};
|
|
|
|
let left_content = self.baseline_by_path.get(left_path);
|
|
let right_content = self.current_by_path.get(right_path);
|
|
let key = DiffCacheKey {
|
|
left_path: left_path.clone(),
|
|
left_revision: left_content.map(|content| content.revision),
|
|
right_path: right_path.clone(),
|
|
right_revision: right_content.map(|content| content.revision),
|
|
};
|
|
let rendered = previous_diffs.remove(&key).unwrap_or_else(|| {
|
|
self.render_diff(
|
|
left_path,
|
|
left_content.map(|content| content.content.as_str()),
|
|
right_path,
|
|
right_content.map(|content| content.content.as_str()),
|
|
)
|
|
});
|
|
|
|
if let Some(diff) = rendered.as_deref() {
|
|
aggregated.push_str(diff);
|
|
if !aggregated.ends_with('\n') {
|
|
aggregated.push('\n');
|
|
}
|
|
}
|
|
rendered_diffs.insert(key, rendered);
|
|
}
|
|
|
|
self.rendered_diffs = rendered_diffs;
|
|
self.unified_diff = (!aggregated.is_empty()).then_some(aggregated);
|
|
}
|
|
|
|
fn apply_change(&mut self, environment_id: &str, change: &AppliedPatchChange) {
|
|
let source_path = TrackedPath::new(environment_id, change.path.as_path());
|
|
match &change.change {
|
|
AppliedPatchFileChange::Add {
|
|
content,
|
|
overwritten_content,
|
|
} => self.apply_add(source_path, content, overwritten_content.as_deref()),
|
|
AppliedPatchFileChange::Delete { content } => self.apply_delete(source_path, content),
|
|
AppliedPatchFileChange::Update {
|
|
move_path,
|
|
old_content,
|
|
overwritten_move_content,
|
|
new_content,
|
|
} => {
|
|
let move_path = move_path
|
|
.as_deref()
|
|
.map(|path| TrackedPath::new(environment_id, path));
|
|
self.apply_update(
|
|
source_path,
|
|
move_path,
|
|
old_content,
|
|
overwritten_move_content.as_deref(),
|
|
new_content,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_add(&mut self, path: TrackedPath, content: &str, overwritten_content: Option<&str>) {
|
|
self.origin_by_current_path.remove(&path);
|
|
if !self.current_by_path.contains_key(&path)
|
|
&& !self.baseline_by_path.contains_key(&path)
|
|
&& let Some(overwritten_content) = overwritten_content
|
|
{
|
|
let overwritten_content = self.tracked_content(overwritten_content);
|
|
self.baseline_by_path
|
|
.insert(path.clone(), overwritten_content);
|
|
}
|
|
let content = self.tracked_content(content);
|
|
self.current_by_path.insert(path, content);
|
|
}
|
|
|
|
fn apply_delete(&mut self, path: TrackedPath, content: &str) {
|
|
if self.current_by_path.remove(&path).is_none()
|
|
&& !self.baseline_by_path.contains_key(&path)
|
|
{
|
|
let content = self.tracked_content(content);
|
|
self.baseline_by_path.insert(path.clone(), content);
|
|
}
|
|
self.origin_by_current_path.remove(&path);
|
|
}
|
|
|
|
fn apply_update(
|
|
&mut self,
|
|
source_path: TrackedPath,
|
|
move_path: Option<TrackedPath>,
|
|
old_content: &str,
|
|
overwritten_move_content: Option<&str>,
|
|
new_content: &str,
|
|
) {
|
|
if !self.current_by_path.contains_key(&source_path)
|
|
&& !self.baseline_by_path.contains_key(&source_path)
|
|
{
|
|
let old_content = self.tracked_content(old_content);
|
|
self.baseline_by_path
|
|
.insert(source_path.clone(), old_content);
|
|
}
|
|
|
|
match move_path {
|
|
Some(dest_path) => {
|
|
if !self.current_by_path.contains_key(&dest_path)
|
|
&& !self.baseline_by_path.contains_key(&dest_path)
|
|
&& let Some(overwritten_move_content) = overwritten_move_content
|
|
{
|
|
let overwritten_move_content = self.tracked_content(overwritten_move_content);
|
|
self.baseline_by_path
|
|
.insert(dest_path.clone(), overwritten_move_content);
|
|
}
|
|
let origin = self
|
|
.origin_by_current_path
|
|
.remove(&source_path)
|
|
.unwrap_or_else(|| source_path.clone());
|
|
self.current_by_path.remove(&source_path);
|
|
let new_content = self.tracked_content(new_content);
|
|
self.current_by_path.insert(dest_path.clone(), new_content);
|
|
self.origin_by_current_path.remove(&dest_path);
|
|
if dest_path != origin {
|
|
self.origin_by_current_path.insert(dest_path, origin);
|
|
}
|
|
}
|
|
None => {
|
|
let new_content = self.tracked_content(new_content);
|
|
self.current_by_path.insert(source_path, new_content);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tracked_content(&mut self, content: &str) -> TrackedContent {
|
|
let revision = self.next_revision;
|
|
self.next_revision += 1;
|
|
TrackedContent {
|
|
content: content.to_string(),
|
|
revision,
|
|
}
|
|
}
|
|
|
|
fn rename_pairs(&self) -> HashMap<TrackedPath, TrackedPath> {
|
|
self.origin_by_current_path
|
|
.iter()
|
|
.filter_map(|(dest_path, origin_path)| {
|
|
if dest_path == origin_path
|
|
|| self.current_by_path.contains_key(origin_path)
|
|
|| !self.current_by_path.contains_key(dest_path)
|
|
|| !self.baseline_by_path.contains_key(origin_path)
|
|
|| self.baseline_by_path.contains_key(dest_path)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some((origin_path.clone(), dest_path.clone()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn render_diff(
|
|
&self,
|
|
left_path: &TrackedPath,
|
|
left_content: Option<&str>,
|
|
right_path: &TrackedPath,
|
|
right_content: Option<&str>,
|
|
) -> Option<String> {
|
|
if left_content == right_content {
|
|
return None;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
self.rendered_diff_count
|
|
.set(self.rendered_diff_count.get() + 1);
|
|
|
|
let left_display = self.display_path(left_path);
|
|
let right_display = self.display_path(right_path);
|
|
let left_oid = left_content.map_or_else(
|
|
|| ZERO_OID.to_string(),
|
|
|content| git_blob_oid(content.as_bytes()),
|
|
);
|
|
let right_oid = right_content.map_or_else(
|
|
|| ZERO_OID.to_string(),
|
|
|content| git_blob_oid(content.as_bytes()),
|
|
);
|
|
|
|
let mut diff = format!("diff --git a/{left_display} b/{right_display}\n");
|
|
match (left_content, right_content) {
|
|
(None, Some(_)) => diff.push_str(&format!("new file mode {REGULAR_FILE_MODE}\n")),
|
|
(Some(_), None) => diff.push_str(&format!("deleted file mode {REGULAR_FILE_MODE}\n")),
|
|
(Some(_), Some(_)) => {}
|
|
(None, None) => return None,
|
|
}
|
|
|
|
diff.push_str(&format!("index {left_oid}..{right_oid}\n"));
|
|
|
|
let old_header = if left_content.is_some() {
|
|
format!("a/{left_display}")
|
|
} else {
|
|
DEV_NULL.to_string()
|
|
};
|
|
let new_header = if right_content.is_some() {
|
|
format!("b/{right_display}")
|
|
} else {
|
|
DEV_NULL.to_string()
|
|
};
|
|
|
|
let mut config = similar::TextDiff::configure();
|
|
config.timeout(DIFF_TIMEOUT);
|
|
let unified = config
|
|
.diff_lines(left_content.unwrap_or(""), right_content.unwrap_or(""))
|
|
.unified_diff()
|
|
.context_radius(3)
|
|
.header(&old_header, &new_header)
|
|
.to_string();
|
|
diff.push_str(&unified);
|
|
Some(diff)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn rendered_diff_count(&self) -> usize {
|
|
self.rendered_diff_count.get()
|
|
}
|
|
|
|
fn display_path(&self, path: &TrackedPath) -> String {
|
|
let display = self
|
|
.display_roots_by_environment
|
|
.get(&path.environment_id)
|
|
.and_then(|root| path.path.strip_prefix(root).ok())
|
|
.unwrap_or(path.path.as_path());
|
|
let display = display.display().to_string().replace('\\', "/");
|
|
if self.display_roots_by_environment.len() > 1 && !path.environment_id.is_empty() {
|
|
format!("{}/{display}", path.environment_id)
|
|
} else {
|
|
display
|
|
}
|
|
}
|
|
}
|
|
|
|
fn git_blob_oid(data: &[u8]) -> String {
|
|
format!("{:x}", git_blob_sha1_hex_bytes(data))
|
|
}
|
|
|
|
/// Compute the Git SHA-1 blob object ID for the given content (bytes).
|
|
fn git_blob_sha1_hex_bytes(data: &[u8]) -> Output<sha1::Sha1> {
|
|
let header = format!("blob {}\0", data.len());
|
|
use sha1::Digest;
|
|
let mut hasher = sha1::Sha1::new();
|
|
hasher.update(header.as_bytes());
|
|
hasher.update(data);
|
|
hasher.finalize()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "turn_diff_tracker_tests.rs"]
|
|
mod tests;
|