fix: preserve exact turn diffs after partial apply_patch failures (#21518)

## Why

Follow-up to #21180: turn diffs are operation-backed now, but a failed
`apply_patch` can still leave exact filesystem mutations behind. For
example, a move can write the destination file before failing to remove
the source. Treating the whole call as unknowable then drops a change
that Codex actually knows happened, so the emitted turn diff can drift
from the workspace.

## What changed

-
[`apply-patch`](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/apply-patch/src/lib.rs#L248-L345)
now returns `ApplyPatchFailure` with the exact committed prefix
accumulated before an error. If a write failure may already have mutated
the target, the delta is marked inexact instead of being reused blindly.
- Move handling now records the destination write before attempting
source removal, so a partially failed move can still report the
destination file that definitely landed
([code](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/apply-patch/src/lib.rs#L463-L521)).
-
[`ApplyPatchRuntime`](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/core/src/tools/runtimes/apply_patch.rs#L49-L67)
now accumulates committed deltas across attempts and forwards them even
when the visible tool result is failed or sandbox-denied ([runtime
path](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/core/src/tools/runtimes/apply_patch.rs#L223-L250),
[event
path](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/core/src/tools/events.rs#L215-L225)).
- `TurnDiffTracker` now consumes committed exact deltas rather than only
fully successful patches; exact-empty failures leave the aggregate
unchanged, while inexact deltas still invalidate it.

## Verification

- Added a regression test covering a failed move that still emits the
committed destination diff:
[`apply_patch_failed_move_preserves_committed_destination_diff`](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/core/tests/suite/apply_patch_cli.rs#L1517-L1586).
- Kept explicit coverage that an inexact delta clears the aggregate
instead of publishing a guessed diff:
[`apply_patch_clears_aggregated_diff_after_inexact_delta`](https://github.com/openai/codex/blob/f55724e0276a9b3213170daf2701ccfa0ce22646/codex-rs/core/tests/suite/apply_patch_cli.rs#L1589-L1655).

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
jif-oai
2026-05-07 18:05:45 +02:00
committed by GitHub
co-authored by Codex
parent e64a8979b0
commit 9b6c6f7a01
7 changed files with 474 additions and 143 deletions
+146 -20
View File
@@ -57,13 +57,16 @@ pub(crate) enum ToolEventStage<'a> {
output: ExecToolCallOutput,
applied_patch_delta: Option<&'a AppliedPatchDelta>,
},
Failure(ToolEventFailure),
Failure(ToolEventFailure<'a>),
}
pub(crate) enum ToolEventFailure {
pub(crate) enum ToolEventFailure<'a> {
Output(ExecToolCallOutput),
Message(String),
Rejected(String),
Rejected {
message: String,
applied_patch_delta: Option<&'a AppliedPatchDelta>,
},
}
enum TurnDiffTrackerUpdate<'a> {
@@ -72,6 +75,14 @@ enum TurnDiffTrackerUpdate<'a> {
None,
}
fn tracker_update_for_known_delta(delta: &AppliedPatchDelta) -> TurnDiffTrackerUpdate<'_> {
if delta.is_exact() && delta.is_empty() {
TurnDiffTrackerUpdate::None
} else {
TurnDiffTrackerUpdate::Track(delta)
}
}
pub(crate) async fn emit_exec_command_begin(
ctx: ToolEventCtx<'_>,
command: &[String],
@@ -217,15 +228,9 @@ impl ToolEmitter {
} else {
PatchApplyStatus::Failed
};
let tracker_update = if output.exit_code == 0 {
if let Some(delta) = applied_patch_delta {
TurnDiffTrackerUpdate::Track(delta)
} else {
TurnDiffTrackerUpdate::Invalidate
}
} else {
TurnDiffTrackerUpdate::Invalidate
};
let tracker_update = applied_patch_delta
.map(tracker_update_for_known_delta)
.unwrap_or(TurnDiffTrackerUpdate::Invalidate);
emit_patch_end(
ctx,
changes.clone(),
@@ -270,7 +275,10 @@ impl ToolEmitter {
}
(
Self::ApplyPatch { changes, .. },
ToolEventStage::Failure(ToolEventFailure::Rejected(message)),
ToolEventStage::Failure(ToolEventFailure::Rejected {
message,
applied_patch_delta,
}),
) => {
emit_patch_end(
ctx,
@@ -278,7 +286,9 @@ impl ToolEmitter {
String::new(),
(*message).to_string(),
PatchApplyStatus::Declined,
TurnDiffTrackerUpdate::None,
applied_patch_delta
.map(tracker_update_for_known_delta)
.unwrap_or(TurnDiffTrackerUpdate::None),
)
.await;
}
@@ -347,13 +357,27 @@ impl ToolEmitter {
};
(event, result)
}
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output })))
| Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, .. }))) => {
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => {
let response = self.format_exec_output_for_model(&output, ctx);
let event = ToolEventStage::Failure(ToolEventFailure::Output(*output));
let result = Err(FunctionCallError::RespondToModel(response));
(event, result)
}
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, .. }))) => {
let response = self.format_exec_output_for_model(&output, ctx);
// apply_patch can be denied after it has already committed a
// known prefix. Reuse the output-bearing path so the visible
// item still fails while the turn diff consumes that prefix.
let event = match (self, applied_patch_delta) {
(Self::ApplyPatch { .. }, Some(delta)) => ToolEventStage::Success {
output: *output,
applied_patch_delta: Some(delta),
},
_ => ToolEventStage::Failure(ToolEventFailure::Output(*output)),
};
let result = Err(FunctionCallError::RespondToModel(response));
(event, result)
}
Err(ToolError::Codex(err)) => {
let message = format!("execution error: {err:?}");
let event = ToolEventStage::Failure(ToolEventFailure::Message(message.clone()));
@@ -380,7 +404,10 @@ impl ToolEmitter {
} else {
msg
};
let event = ToolEventStage::Failure(ToolEventFailure::Rejected(normalized.clone()));
let event = ToolEventStage::Failure(ToolEventFailure::Rejected {
message: normalized.clone(),
applied_patch_delta,
});
let result = Err(FunctionCallError::RespondToModel(normalized));
(event, result)
}
@@ -477,7 +504,7 @@ async fn emit_exec_stage(
};
emit_exec_end(ctx, exec_input, exec_result).await;
}
ToolEventStage::Failure(ToolEventFailure::Rejected(message)) => {
ToolEventStage::Failure(ToolEventFailure::Rejected { message, .. }) => {
let text = message.to_string();
let exec_result = ExecCommandResult {
stdout: String::new(),
@@ -550,8 +577,8 @@ async fn emit_patch_end(
let mut guard = tracker.lock().await;
let previous_diff = guard.get_unified_diff();
let tracker_changed = match tracker_update {
TurnDiffTrackerUpdate::Track(action) => {
guard.track_successful_patch(action);
TurnDiffTrackerUpdate::Track(delta) => {
guard.track_delta(delta);
true
}
TurnDiffTrackerUpdate::Invalidate => {
@@ -573,3 +600,102 @@ async fn emit_patch_end(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::tests::make_session_and_context_with_dynamic_tools_and_rx;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_exec_server::LOCAL_FS;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::PatchApplyStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::Mutex;
async fn assert_failed_apply_patch_tracks_committed_delta(
out: Result<ExecToolCallOutput, ToolError>,
expected_status: PatchApplyStatus,
) {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
let dir = tempdir().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let delta = codex_apply_patch::apply_patch(
"*** Begin Patch\n*** Add File: out/dest.txt\n+after\n*** End Patch",
&cwd,
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.expect("apply patch");
ToolEmitter::apply_patch(HashMap::new(), /*auto_approved*/ false)
.finish(
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
out,
Some(&delta),
)
.await
.expect_err("failed patch");
let completed = rx_event.recv().await.expect("item completed event");
assert!(matches!(
completed.msg,
EventMsg::ItemCompleted(event)
if matches!(
&event.item,
TurnItem::FileChange(FileChangeItem {
status: Some(status),
..
}) if status == &expected_status
)
));
let unified_diff = loop {
let event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv())
.await
.expect("turn diff event")
.expect("channel open");
if let EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) = event.msg {
break unified_diff;
}
};
assert!(unified_diff.contains("out/dest.txt"));
assert!(unified_diff.contains("+after"));
}
#[tokio::test]
async fn denied_apply_patch_tracks_committed_delta() {
let output = ExecToolCallOutput {
exit_code: 1,
..Default::default()
};
assert_failed_apply_patch_tracks_committed_delta(
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
}))),
PatchApplyStatus::Failed,
)
.await;
}
#[tokio::test]
async fn rejected_apply_patch_tracks_committed_delta() {
assert_failed_apply_patch_tracks_committed_delta(
Err(ToolError::Rejected("rejected by user".to_string())),
PatchApplyStatus::Declined,
)
.await;
}
}
@@ -438,8 +438,8 @@ impl ToolHandler for ApplyPatchHandler {
.await
.map(|result| result.output);
let (out, delta) = match out {
Ok(output) => (Ok(output.exec_output), output.delta),
Err(error) => (Err(error), None),
Ok(output) => (Ok(output.exec_output), Some(output.delta)),
Err(error) => (Err(error), Some(runtime.committed_delta().clone())),
};
let event_ctx = ToolEventCtx::new(
session.as_ref(),
@@ -550,8 +550,8 @@ pub(crate) async fn intercept_apply_patch(
.await
.map(|result| result.output);
let (out, delta) = match out {
Ok(output) => (Ok(output.exec_output), output.delta),
Err(error) => (Err(error), None),
Ok(output) => (Ok(output.exec_output), Some(output.delta)),
Err(error) => (Err(error), Some(runtime.committed_delta().clone())),
};
let event_ctx = ToolEventCtx::new(
session.as_ref(),
@@ -47,17 +47,23 @@ pub struct ApplyPatchRequest {
}
#[derive(Default)]
pub struct ApplyPatchRuntime;
pub struct ApplyPatchRuntime {
committed_delta: AppliedPatchDelta,
}
#[derive(Debug)]
pub struct ApplyPatchRuntimeOutput {
pub exec_output: ExecToolCallOutput,
pub delta: Option<AppliedPatchDelta>,
pub delta: AppliedPatchDelta,
}
impl ApplyPatchRuntime {
pub fn new() -> Self {
Self
Self::default()
}
pub fn committed_delta(&self) -> &AppliedPatchDelta {
&self.committed_delta
}
fn build_guardian_review_request(
@@ -217,7 +223,13 @@ impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> for ApplyPatchRunti
.await;
let stdout = String::from_utf8_lossy(&stdout).into_owned();
let stderr = String::from_utf8_lossy(&stderr).into_owned();
let exit_code = if result.is_ok() { 0 } else { 1 };
let failed = result.is_err();
let exit_code = if failed { 1 } else { 0 };
let delta = match result {
Ok(delta) => delta,
Err(failure) => failure.into_parts().1,
};
self.committed_delta.append(delta);
let output = ExecToolCallOutput {
exit_code,
stdout: StreamOutput::new(stdout.clone()),
@@ -226,7 +238,7 @@ impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> for ApplyPatchRunti
duration: started_at.elapsed(),
timed_out: false,
};
if result.is_err() && is_likely_sandbox_denied(attempt.sandbox, &output) {
if failed && is_likely_sandbox_denied(attempt.sandbox, &output) {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
@@ -234,7 +246,7 @@ impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> for ApplyPatchRunti
}
Ok(ApplyPatchRuntimeOutput {
exec_output: output,
delta: result.ok(),
delta: self.committed_delta.clone(),
})
}
}
+3 -3
View File
@@ -13,8 +13,8 @@ const ZERO_OID: &str = "0000000000000000000000000000000000000000";
const DEV_NULL: &str = "/dev/null";
const REGULAR_FILE_MODE: &str = "100644";
/// Tracks the net text diff for the current turn from successful apply_patch
/// operations, without rereading the workspace filesystem.
/// 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_root: Option<PathBuf>,
@@ -46,7 +46,7 @@ impl TurnDiffTracker {
tracker
}
pub fn track_successful_patch(&mut self, delta: &AppliedPatchDelta) {
pub fn track_delta(&mut self, delta: &AppliedPatchDelta) {
if !delta.is_exact() {
self.invalidate();
return;
+12 -12
View File
@@ -51,14 +51,14 @@ async fn accumulates_add_then_update_as_single_add() {
"*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch",
)
.await;
tracker.track_successful_patch(&add);
tracker.track_delta(&add);
let update = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Update File: a.txt\n@@\n foo\n+bar\n*** End Patch",
)
.await;
tracker.track_successful_patch(&update);
tracker.track_delta(&update);
let right_oid = git_blob_sha1_hex("foo\nbar\n");
let expected = format!(
@@ -85,7 +85,7 @@ async fn invalidated_tracker_suppresses_existing_diff() {
"*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch",
)
.await;
tracker.track_successful_patch(&add);
tracker.track_delta(&add);
tracker.invalidate();
@@ -103,7 +103,7 @@ async fn accumulates_delete() {
"*** Begin Patch\n*** Delete File: b.txt\n*** End Patch",
)
.await;
tracker.track_successful_patch(&delete);
tracker.track_delta(&delete);
let left_oid = git_blob_sha1_hex("x\n");
let expected = format!(
@@ -130,7 +130,7 @@ async fn accumulates_move_and_update() {
"*** Begin Patch\n*** Update File: src.txt\n*** Move to: dst.txt\n@@\n-line\n+line2\n*** End Patch",
)
.await;
tracker.track_successful_patch(&update);
tracker.track_delta(&update);
let left_oid = git_blob_sha1_hex("line\n");
let right_oid = git_blob_sha1_hex("line2\n");
@@ -158,7 +158,7 @@ async fn pure_rename_yields_no_diff() {
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n same\n*** End Patch",
)
.await;
tracker.track_successful_patch(&rename);
tracker.track_delta(&rename);
assert_eq!(tracker.get_unified_diff(), None);
}
@@ -174,7 +174,7 @@ async fn add_over_existing_file_becomes_update() {
"*** Begin Patch\n*** Add File: dup.txt\n+after\n*** End Patch",
)
.await;
tracker.track_successful_patch(&add);
tracker.track_delta(&add);
let left_oid = git_blob_sha1_hex("before\n");
let right_oid = git_blob_sha1_hex("after\n");
@@ -202,14 +202,14 @@ async fn delete_then_readd_same_path_becomes_update() {
"*** Begin Patch\n*** Delete File: cycle.txt\n*** End Patch",
)
.await;
tracker.track_successful_patch(&delete);
tracker.track_delta(&delete);
let add = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Add File: cycle.txt\n+after\n*** End Patch",
)
.await;
tracker.track_successful_patch(&add);
tracker.track_delta(&add);
let left_oid = git_blob_sha1_hex("before\n");
let right_oid = git_blob_sha1_hex("after\n");
@@ -238,7 +238,7 @@ async fn move_over_existing_destination_without_content_change_deletes_source_on
"*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n same\n*** End Patch",
)
.await;
tracker.track_successful_patch(&move_overwrite);
tracker.track_delta(&move_overwrite);
let left_oid = git_blob_sha1_hex("same\n");
let expected = format!(
@@ -267,7 +267,7 @@ async fn move_over_existing_destination_with_content_change_deletes_source_and_u
"*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch",
)
.await;
tracker.track_successful_patch(&move_overwrite);
tracker.track_delta(&move_overwrite);
let left_oid_a = git_blob_sha1_hex("from\n");
let left_oid_b = git_blob_sha1_hex("existing\n");
@@ -304,7 +304,7 @@ async fn preserves_committed_change_order_with_delete_then_move_overwrite() {
"*** Begin Patch\n*** Delete File: b.txt\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch",
)
.await;
tracker.track_successful_patch(&ordered_patch);
tracker.track_delta(&ordered_patch);
let left_oid_a = git_blob_sha1_hex("from\n");
let left_oid_b = git_blob_sha1_hex("existing\n");