From 8a3e8aa0aedc1a69c98f4f491db342c9aac52650 Mon Sep 17 00:00:00 2001 From: Mohamed Elsheikh Date: Mon, 19 Oct 2026 22:00:00 +0200 Subject: [PATCH] feat(session,todo): purge on-disk and in-memory board state on delete and inherit board on fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean up orphaned checklist and permission files on session deletion, evict active chat session state from memory, and preserve reactive TodoBoard state across conversation forks. 1. Orphaned Disk and Memory Cleanup on Session Delete: - Added todo_board_path (.todo) and pending_permission_path (pending_permission.json) to the deletion targets in SessionManager::delete, eliminating orphaned files left on disk after session deletion. - Introduced purge_todo_board(session_id) in crates/jeikcode-capabilities/src/tools/todo.rs to evict the session entry from the global todo_registry() and clean up associated LiveBindings upon deletion, and re-exported TodoStatus in tools/mod.rs. - Added ActiveChatRegistry::clear_session in crates/jeikcode-daemon/src/lib.rs to evict session_seq, session_terminal, standby_watchers, and aliases when a session is deleted, preventing memory retention over long-running daemon lifecycles. 2. Reactive Checklist Continuity across Conversation Branching: - Updated SessionManager::fork_native_session_at to copy .todo to .todo on disk when present. - Cloned and restored the in-memory TodoBoard for the forked session identity via read_todo_board -> restore_todo_board, ensuring branched conversations inherit the active task checklist with complete on-disk and in-memory parity. --- 在会话删除时彻底清理磁盘孤儿待办与待确认权限文件,释放内存中残留的会话活跃状态,并在会话派生分支时完整继承 TodoBoard 状态。 1. 会话删除磁盘与内存状态彻底回收: - 在 SessionManager::delete 的删除目标中补齐 todo_board_path(.todo)与 pending_permission_path(pending_permission.json),根除会话被删除后残留在磁盘上的孤儿文件; - 在 crates/jeikcode-capabilities/src/tools/todo.rs 中新增 purge_todo_board(session_id),在会话销毁时从全局 todo_registry() 中完全移除该槽位并清理关联的 LiveBinding,并在 tools/mod.rs 中统一重导出 TodoStatus; - 在 crates/jeikcode-daemon/src/lib.rs 的 ActiveChatRegistry 中新增 clear_session 方法,在删除会话时同步注销 session_seq、session_terminal、standby_watchers 及别名映射,避免守护进程长时间运行下的内存泄露。 2. 会话分支派生(Fork)任务看板连续性保障: - 优化 SessionManager::fork_native_session_at,在派生分支时自动将源会话磁盘上的 .todo 复制为新会话的 .todo; - 通过 read_todo_board -> restore_todo_board 将内存中处于活跃状态的 TodoBoard 完整复刻给新会话,确保用户切分支/分叉对话时任务清单具备 100% 内存与磁盘一致性。 Co-Authored-By: JeikCode --- .../src/session/manager.rs | 102 ++++++++++++++++++ crates/jeikcode-capabilities/src/tools/mod.rs | 4 +- .../jeikcode-capabilities/src/tools/todo.rs | 14 +++ crates/jeikcode-daemon/src/lib.rs | 50 +++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) 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();