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.