Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/server/src/production/orchestration_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/production/shared_checkout_restore_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture/rpc-and-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading