diff --git a/crates/jeikcode-capabilities/src/session/manager.rs b/crates/jeikcode-capabilities/src/session/manager.rs index 10ec6bb04..204497a70 100644 --- a/crates/jeikcode-capabilities/src/session/manager.rs +++ b/crates/jeikcode-capabilities/src/session/manager.rs @@ -2142,6 +2142,17 @@ impl SessionManager { let _ = fs::copy(&src, &dst); } } + if let (Ok(src), Ok(dst)) = ( + self.todo_board_path(source_id), + self.todo_board_path(destination_id), + ) { + if src.is_file() { + let _ = fs::copy(&src, &dst); + } + } + if let Some(board) = crate::tools::read_todo_board(source_id) { + crate::tools::restore_todo_board(destination_id, board); + } Ok((forked, destination_lease)) } @@ -3149,6 +3160,8 @@ impl SessionManager { self.presentation_path(id)?, self.legacy_path(id)?, self.bashkw_path(id)?, + self.todo_board_path(id)?, + self.pending_permission_path(id)?, ]; for path in &targets { validate_delete_target(path)?; @@ -3163,6 +3176,7 @@ impl SessionManager { if let Ok(dir) = self.artifacts_dir(id) { let _ = fs::remove_dir_all(&dir); // best-effort; absent dir is fine } + crate::tools::purge_todo_board(id); Ok(()) } @@ -6203,6 +6217,10 @@ mod tests { .unwrap(); let lease = mgr.acquire_lease("s1").unwrap(); + std::fs::write(mgr.todo_board_path("s1").unwrap(), b"{}").unwrap(); + std::fs::write(mgr.pending_permission_path("s1").unwrap(), b"{}").unwrap(); + assert!(mgr.todo_board_path("s1").unwrap().exists()); + assert!(mgr.pending_permission_path("s1").unwrap().exists()); mgr.save_rewind_transaction_with_lease( &lease, &super::super::rewind::RewindTransactionJournal { @@ -6222,10 +6240,94 @@ mod tests { assert!(!mgr.jsonl_path("s1").unwrap().exists()); assert!(!mgr.presentation_path("s1").unwrap().exists()); assert!(!mgr.rewind_transaction_path("s1").unwrap().exists()); + assert!(!mgr.todo_board_path("s1").unwrap().exists()); + assert!(!mgr.pending_permission_path("s1").unwrap().exists()); // Idempotent: deleting again is fine. mgr.delete(&lease).unwrap(); } + #[test] + fn delete_purges_todo_board_from_memory() { + let dir = tempfile::tempdir().unwrap(); + let mgr = SessionManager::with_root(dir.path()); + mgr.write_meta(&SessionMeta::new("s_purge", "/p", 1)) + .unwrap(); + mgr.save_snapshot("s_purge", &snap(&["task turn"])).unwrap(); + + let item = crate::tools::TodoItem { + content: "in-memory task to purge".into(), + status: crate::tools::TodoStatus::InProgress, + }; + crate::tools::commit_todo_board("s_purge", &[item]); + assert!(crate::tools::read_todo_board("s_purge").is_some()); + + let lease = mgr.acquire_lease("s_purge").unwrap(); + mgr.delete(&lease).unwrap(); + assert!( + crate::tools::read_todo_board("s_purge").is_none(), + "in-memory todo board must be fully purged on session delete" + ); + } + + #[test] + fn fork_native_session_preserves_todo_board_on_disk_and_in_memory() { + let dir = tempfile::tempdir().unwrap(); + let mgr = SessionManager::with_root(dir.path()); + let source_lease = mgr.acquire_lease("source_todo").unwrap(); + let snapshot = snap(&["first turn", "second turn"]); + let presentation = PresentationFile { + v: PRESENTATION_VERSION, + entries: vec![], + }; + let mut source_meta = SessionMeta::new("source_todo", "/project", 10); + source_meta.owner = StorageOwner::Native; + source_meta.turn_count = 2; + source_meta.message_count = 2; + mgr.commit_native_import( + &source_lease, + Some(&snapshot), + Some(&presentation), + &source_meta, + ) + .unwrap(); + + let task = crate::tools::TodoItem { + content: "retained branched task".into(), + status: crate::tools::TodoStatus::InProgress, + }; + crate::tools::commit_todo_board("source_todo", &[task.clone()]); + mgr.save_snapshot("source_todo", &snapshot).unwrap(); + assert!(mgr.todo_board_path("source_todo").unwrap().is_file()); + + let (forked, fork_lease) = mgr + .fork_native_session("source_todo", "dest_todo", 20) + .unwrap(); + + assert_eq!(forked.meta.id, "dest_todo"); + assert!( + mgr.todo_board_path("dest_todo").unwrap().is_file(), + "forked session must inherit persisted .todo file on disk" + ); + + let inherited_board = crate::tools::read_todo_board("dest_todo") + .expect("forked session must inherit live todo board in memory"); + assert_eq!(inherited_board.items.len(), 1); + assert_eq!(inherited_board.items[0].content, "retained branched task"); + assert_eq!( + inherited_board.items[0].status, + crate::tools::TodoStatus::InProgress + ); + + // Cleanup + mgr.delete(&fork_lease).unwrap(); + assert!(!mgr.todo_board_path("dest_todo").unwrap().exists()); + assert!(crate::tools::read_todo_board("dest_todo").is_none()); + + mgr.delete(&source_lease).unwrap(); + assert!(!mgr.todo_board_path("source_todo").unwrap().exists()); + assert!(crate::tools::read_todo_board("source_todo").is_none()); + } + #[test] fn native_runtime_mutation_commits_snapshot_presentation_then_meta_under_lease() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/jeikcode-capabilities/src/tools/mod.rs b/crates/jeikcode-capabilities/src/tools/mod.rs index 0bb86b0fb..0f417af18 100644 --- a/crates/jeikcode-capabilities/src/tools/mod.rs +++ b/crates/jeikcode-capabilities/src/tools/mod.rs @@ -98,9 +98,9 @@ pub use sensitive_path::{path_is_sensitive, references_sensitive_path, Sensitive pub use task::TaskTool; pub use todo::{ bind_todo_session, bind_todowrite, clear_todo_board, close_todo_board_if_complete, - commit_todo_board, derive_current_todos, is_todo_tool_name, read_todo_board, + commit_todo_board, derive_current_todos, is_todo_tool_name, purge_todo_board, read_todo_board, restore_todo_board, session_sticky_todos, todo_action_kind, todo_session_is_closed, - unbind_todo_session, TodoBoard, TodoItem, TodoLive, TodoTool, TODO_TOOL_ALIASES, + unbind_todo_session, TodoBoard, TodoItem, TodoLive, TodoStatus, TodoTool, TODO_TOOL_ALIASES, TODO_TOOL_NAME, }; #[cfg(feature = "web")] diff --git a/crates/jeikcode-capabilities/src/tools/todo.rs b/crates/jeikcode-capabilities/src/tools/todo.rs index 3d067a264..e7515563a 100644 --- a/crates/jeikcode-capabilities/src/tools/todo.rs +++ b/crates/jeikcode-capabilities/src/tools/todo.rs @@ -1064,6 +1064,20 @@ pub fn clear_todo_board(session_id: &str) { ); } +/// Fully purge the session slot and any associated live bindings when a session is deleted. +pub fn purge_todo_board(session_id: &str) { + let mut guard = todo_registry() + .write() + .unwrap_or_else(|err| err.into_inner()); + guard.remove(session_id); + drop(guard); + + let mut bindings = live_bindings() + .lock() + .unwrap_or_else(|err| err.into_inner()); + bindings.retain(|_, b| b.session_id != session_id && b.owner.strong_count() > 0); +} + /// After streaming has stopped, drop the live board only when every item is checked. /// Returns the closed board so the caller can keep it as history. Open items stay. pub fn close_todo_board_if_complete(session_id: &str) -> Option { diff --git a/crates/jeikcode-daemon/src/lib.rs b/crates/jeikcode-daemon/src/lib.rs index acf17c340..ff3802e06 100644 --- a/crates/jeikcode-daemon/src/lib.rs +++ b/crates/jeikcode-daemon/src/lib.rs @@ -2049,6 +2049,16 @@ impl ActiveChatRegistry { } } + /// Evict all in-memory tracking state (sequences, cached terminal frames, standby watchers, aliases) + /// when a session is deleted, preventing resource leakage across session lifecycles. + pub(crate) async fn clear_session(&self, session_id: &str) { + let mut index = self.inner.write().await; + index.session_seq.remove(session_id); + index.session_terminal.remove(session_id); + index.standby_watchers.remove(session_id); + index.aliases.remove(session_id); + } + async fn active_session_ids(&self) -> Vec { let mut sessions: Vec = self .inner @@ -5423,6 +5433,7 @@ async fn delete_session( Ok(Ok(())) => { let working_dir = state_clone.project.read().await.working_dir.clone(); let id_for_cleanup = id.clone(); + let active_chats_cleanup = state_clone.active_chats.clone(); // Session files are already gone. Reap MCP, kill session background tasks, // and clean up session background log files asynchronously. tokio::spawn(async move { @@ -5438,6 +5449,9 @@ async fn delete_session( // Release session-scoped approval mode override crate::live_api::live_clear_session_approval_mode(&id_for_cleanup); + + // Clean up active chat registry state (session seq, terminal frames, standby watchers) + active_chats_cleanup.clear_session(&id_for_cleanup).await; }); state_clone.telemetry.track(Event::UseCommand { type_: "delete_session".into(), @@ -15391,6 +15405,42 @@ mod tests { ); } + #[tokio::test] + async fn active_chat_registry_clear_session_evicts_all_session_state() { + let registry = ActiveChatRegistry::default(); + let admission = registry.admit(Some("session-evict"), None).await.unwrap(); + registry + .mark_terminal(&admission.operation_id, &done_event("session-evict")) + .await; + assert!(registry.complete(&admission.operation_id).await); + + // Before clear_session: attaching with replay_terminal=true yields the recorded terminal frame + let (tx1, mut rx1) = tokio::sync::mpsc::unbounded_channel(); + let outcome1 = registry + .subscribe_or_standby("session-evict", &tx1, true) + .await; + assert!(matches!(outcome1, WatchOutcome::Standby)); + assert!(matches!( + rx1.try_recv() + .expect("recorded terminal present before clear"), + ChatEvent::Done { .. } + )); + + // Evict session on delete + registry.clear_session("session-evict").await; + + // After clear_session: attaching with replay_terminal=true finds no lingering terminal + let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel(); + let outcome2 = registry + .subscribe_or_standby("session-evict", &tx2, true) + .await; + assert!(matches!(outcome2, WatchOutcome::Standby)); + assert!( + rx2.try_recv().is_err(), + "cleared session must not replay lingering terminal frames" + ); + } + #[tokio::test] async fn stale_chat_cleanup_cannot_remove_a_replacement_operation() { let registry = ActiveChatRegistry::default();