mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user