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
102 changes: 102 additions & 0 deletions crates/jeikcode-capabilities/src/session/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down Expand Up @@ -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)?;
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions crates/jeikcode-capabilities/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
14 changes: 14 additions & 0 deletions crates/jeikcode-capabilities/src/tools/todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TodoBoard> {
Expand Down
50 changes: 50 additions & 0 deletions crates/jeikcode-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let mut sessions: Vec<String> = self
.inner
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down
Loading