From 5456203e7fa725b5503171edb9907697eac3de3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Nov 2026 19:49:33 +0000 Subject: [PATCH] fix(checkpoints): require the token for a deleted chat's later files A file restore whose other chats are all deleted asked for the sibling-changes token only when those chats had turns inside the event window. Files written after this thread's last checkpoint sit past that window, so git restore and git clean deleted them with no acknowledgement. The preview and the worker now use the same outside-change check live siblings already use. Co-Authored-By: mubeda <1380447+mubeda@users.noreply.github.com> --- .../src/production/orchestration_effects.rs | 10 ++++-- .../shared_checkout_restore_tests.rs | 35 +++++++++++++++++++ docs/architecture/rpc-and-orchestration.md | 9 +++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/server/src/production/orchestration_effects.rs b/apps/server/src/production/orchestration_effects.rs index 5190fa76b4..22ed75128d 100644 --- a/apps/server/src/production/orchestration_effects.rs +++ b/apps/server/src/production/orchestration_effects.rs @@ -2809,7 +2809,10 @@ async fn plan_shared_restore( cancellation, ) .await?; - if !changes.turns.is_empty() { + // Turns in the window are not the only outside work: a deleted chat can + // write after this thread's last checkpoint, past the event window, and + // those paths are what `git clean` would delete. + if changes.outside_changes() { acknowledge_sibling_changes(thread_id, turn_count, &changes, token)?; return Ok(( fallback_to_head, @@ -3090,8 +3093,9 @@ pub(crate) async fn preview_checkpoint_revert( ), _ => None, }; - // A deleted chat's changes count only when it changed files since the target. - let changes = changes.filter(|changes| shared || !changes.turns.is_empty()); + // A deleted chat counts when it has turns in the window or files changed since + // this thread's last checkpoint. Either one is work a restore would discard. + let changes = changes.filter(|changes| shared || changes.outside_changes()); let conversation_rewind = match probe { Some(probe) => probe .conversation_rewind(thread_id) diff --git a/apps/server/src/production/shared_checkout_restore_tests.rs b/apps/server/src/production/shared_checkout_restore_tests.rs index 596d02c66a..dd59518e4e 100644 --- a/apps/server/src/production/shared_checkout_restore_tests.rs +++ b/apps/server/src/production/shared_checkout_restore_tests.rs @@ -1541,6 +1541,41 @@ async fn a_sibling_conversation_only_rewind_keeps_its_changes_in_the_warning() { } } +#[tokio::test] +async fn a_deleted_chat_file_change_since_the_last_turn_needs_the_token() { + let fixture = Fixture::new().await; + // B's only session event is before A's turn 1, so the event window has no sibling turn. + // The file was written after A's last checkpoint: a restore would delete it. + fixture.engine.dispatch(command(json!({"type":"thread.delete", "commandId":"delete-b", "threadId":"b", "createdAt":T_LAST}))).await.unwrap(); + std::fs::write(fixture.repo.join("notes.txt"), "kept by a deleted chat\n").unwrap(); + let preview = fixture.preview("a", 1).await; + assert_eq!(preview["siblingChanges"]["paths"], json!(["notes.txt"])); + assert_eq!(preview["siblingChanges"]["turns"], json!([])); + let token = preview["siblingChangesToken"] + .as_str() + .expect("a deleted chat's later files still need acknowledging") + .to_owned(); + let failure = fixture.revert("a", 1, "full", None).await.unwrap_err(); + assert!( + detail(&failure).contains("changed since turn 1"), + "{failure}" + ); + assert_eq!(failure["siblingChangesToken"], json!(token)); + assert_eq!( + std::fs::read_to_string(fixture.repo.join("notes.txt")).unwrap(), + "kept by a deleted chat\n", + "the unacknowledged restore must not delete the file" + ); + assert_eq!(fixture.tracked(), "two\n"); + fixture.revert("a", 1, "full", Some(&token)).await.unwrap(); + assert!( + !fixture.repo.join("notes.txt").exists(), + "the acknowledged restore still cleans the file" + ); + assert_eq!(fixture.tracked(), "one\n"); + fixture.shutdown().await; +} + #[tokio::test] async fn a_terminal_edit_since_the_last_turn_is_listed() { let fixture = Fixture::new().await; diff --git a/docs/architecture/rpc-and-orchestration.md b/docs/architecture/rpc-and-orchestration.md index 0c2883641f..d8e1924b64 100644 --- a/docs/architecture/rpc-and-orchestration.md +++ b/docs/architecture/rpc-and-orchestration.md @@ -3970,9 +3970,12 @@ conversation-rewind support and `siblingChangesToken`: the SHA-256 of the target commit, the current tree id and the sorted event ids. A full revert on a shared checkout proceeds only when its token matches the one recomputed under the gate. A request without a token (an older client) proceeds only when no -outside change exists. Otherwise the refusal carries the new token in its -activity payload. The preview is advisory, runs Git, and is in -`is_long_running`. It needs `orchestration:operate`, as a revert does: its +outside change exists. A checkout whose other chats are all deleted still +counts: their turns in that window, and any files changed since this thread's +last checkpoint, are outside changes. When the token is missing or stale, the +refusal carries the new token in its activity payload. The preview is advisory, +runs Git, and is in `is_long_running`. It needs `orchestration:operate`, as a +revert does: its `git add` into a temporary index runs the repository's clean filters, which can run commands on the host.