diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index af78db2f..4e2e2b38 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -103,6 +103,7 @@ pub async fn handle_delete( // Delete from the content store and CRDT. crate::db::delete_content(&story_id); crate::db::delete_item(&story_id); + let _ = crate::crdt_state::evict_item(&story_id); // Build the response. let stage_label = stage_display_name(&stage); @@ -244,6 +245,53 @@ mod tests { ); } + #[tokio::test] + async fn handle_delete_writes_crdt_tombstone() { + // Initialise the global CRDT singleton (no-op if already done). + crate::crdt_state::init_for_test(); + + let story_id = "9977_story_crdt_tombstone_check"; + let story_number = "9977"; + + // Seed in CRDT. + crate::crdt_state::write_item( + story_id, + "1_backlog", + Some("CRDT Tombstone Check"), + None, + None, + None, + None, + None, + None, + None, + ); + + // Seed in content store so find_story_by_number can resolve it. + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + story_id, + "1_backlog", + "---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n", + ); + + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path(); + let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3002)); + handle_delete("Timmy", story_number, project_root, &agents).await; + + // The CRDT dump includes tombstoned entries — verify is_deleted = true. + let dump = crate::crdt_state::dump_crdt_state(Some(story_id)); + let deleted = dump + .items + .iter() + .any(|i| i.story_id.as_deref() == Some(story_id) && i.is_deleted); + assert!( + deleted, + "CRDT must show is_deleted=true for '{story_id}' after handle_delete" + ); + } + #[tokio::test] async fn handle_delete_removes_story_file_and_confirms() { let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/crdt_state/read.rs b/server/src/crdt_state/read.rs index cab05869..99f51ab0 100644 --- a/server/src/crdt_state/read.rs +++ b/server/src/crdt_state/read.rs @@ -249,8 +249,13 @@ pub fn evict_item(story_id: &str) -> Result<(), String> { // Resolve the item's OpId before the closure (the closure will mutably // borrow `state`, so we can't access `state.crdt.doc.items` from inside). + // + // `rebuild_index` uses `items.iter()` which skips the invisible ROOT + // sentinel, so `idx` is a 0-based visible-item position. `id_at` counts + // ALL non-deleted ops including the sentinel at position 0, so we must + // add 1 to translate from visible-item position to `id_at` position. let item_id = - state.crdt.doc.items.id_at(idx).ok_or_else(|| { + state.crdt.doc.items.id_at(idx + 1).ok_or_else(|| { format!("Item index {idx} for '{story_id}' did not resolve to an OpId") })?;