Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions crates/core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,38 @@ pub struct AutoArchiveExemptions {
pub active: HashSet<String>,
}

/// `root_id` and every thread under it, each parent before its children:
/// the threads archive, unarchive and delete act on together. Empty when
/// `root_id` is not among `sessions`.
pub fn descendant_session_ids<'a>(
sessions: impl IntoIterator<Item = &'a SessionMeta>,
root_id: &str,
) -> Vec<String> {
let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
let mut found = false;
for meta in sessions {
found |= meta.id == root_id;
if let Some(parent) = meta.parent_session_id.as_deref() {
children.entry(parent).or_default().push(&meta.id);
}
}
if !found {
return Vec::new();
}
let mut visited = HashSet::from([root_id]);
let mut output = vec![root_id.to_owned()];
let mut next = 0;
while let Some(id) = output.get(next).cloned() {
for &child in children.get(id.as_str()).into_iter().flatten() {
if visited.insert(child) {
output.push(child.to_owned());
}
}
next += 1;
}
output
}

/// Return the cascade-closed ids eligible for auto-archive in one project.
/// `sessions` may contain only non-archived entries; archived entries are also
/// defensively ignored here so they cannot consume ranking slots.
Expand Down
1 change: 1 addition & 0 deletions crates/runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ term = { path = "../term" }
uuid = { version = "1", features = ["v4"] }

[dev-dependencies]
filetime = "0.2"
mcp-host = { path = "../mcp-host" }
tcode-services = { path = "../services", features = ["test-support"] }
tcode-traverse = { path = "../traverse", default-features = false }
1 change: 0 additions & 1 deletion crates/runtime/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use super::sessions::descendant_session_ids;
use super::*;

impl AppState {
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use tcode_core::attachments::mime_from_path;
use tcode_core::git::{GitAction, GitStatus, build_commit_prompt, sanitize_commit_message};
use tcode_core::project::{
AutoArchiveConfig, AutoArchiveExemptions, Project, SessionMeta, WorktreeInfo, WorktreeSharing,
auto_archive_candidates,
auto_archive_candidates, descendant_session_ids,
};
use tcode_core::provider_status::ProviderSnapshot;
use tcode_core::relay::{
Expand Down
6 changes: 4 additions & 2 deletions crates/runtime/src/app/orchestrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ impl AppState {
);
meta.title = title;
let child_id = meta.id.clone();
let data_dir = self.store.root().clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let resolved_cwd = host_cx
Expand All @@ -572,7 +573,7 @@ impl AppState {
return Err(format!("invalid cwd: {}", canonical.display()));
}
if isolate {
Ok(resolve_child_worktree(canonical, &child_id))
Ok(resolve_child_worktree(canonical, &child_id, &data_dir))
} else {
Ok((canonical, None, None))
}
Expand Down Expand Up @@ -1641,8 +1642,9 @@ pub(super) fn provider_name(provider: ProviderKind) -> &'static str {
fn resolve_child_worktree(
cwd: PathBuf,
child_id: &str,
data_dir: &Path,
) -> (PathBuf, Option<WorktreeInfo>, Option<String>) {
match provision(&cwd, child_id) {
match provision(&cwd, child_id, data_dir) {
Ok(created) => {
let info = WorktreeInfo {
root_project_path: cwd,
Expand Down
189 changes: 107 additions & 82 deletions crates/runtime/src/app/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,8 @@ impl AppState {
}

/// Restore an archived thread (Settings → Archived Threads → Unarchive).
/// Restoring is activity: without it the auto-archive sweep would take an
/// idle thread straight back. A thread that was read stays read.
pub fn unarchive_session(&mut self, session_id: &str, cx: &mut HostCx) {
let Some(archived_at) = self
.sessions
Expand All @@ -786,6 +788,8 @@ impl AppState {
else {
return;
};
let now = now_secs();
let mut visited_changed = false;
let ids = descendant_session_ids(&self.sessions, session_id);
for id in ids {
let Some(mut meta) = self
Expand All @@ -796,10 +800,19 @@ impl AppState {
else {
continue;
};
if let Some(visited) = self.settings.last_visited.get_mut(&id)
&& *visited >= meta.updated_at
{
*visited = now;
visited_changed = true;
}
meta.archived_at = None;
let meta = meta.clone();
meta.updated_at = now;
self.persist_meta(&meta, cx);
}
if visited_changed {
self.persist_settings(cx);
}
}

/// Sweep one project's visible sessions using the configured idle and
Expand Down Expand Up @@ -986,52 +999,93 @@ impl AppState {
Some(fork_id)
}

/// Permanently delete a thread: stop the provider, close its terminal,
/// delete meta + event log, and (when `remove_worktree`) remove the git worktree
/// it was the last user of.
/// Permanently delete a thread and every thread under it (the tree
/// archive acts on): stop their providers, close their terminals, delete
/// their metas and event logs in one transaction, and (when
/// `remove_worktree`) remove each git worktree they own that no remaining
/// thread works in. A worktree they own that is not removed is recorded as
/// kept, so the startup sweep leaves it.
pub fn delete_session(&mut self, session_id: &str, remove_worktree: bool, cx: &mut HostCx) {
self.clear_approvals(session_id);
let meta = self.sessions.iter().find(|m| m.id == session_id).cloned();
if self.residents.live.contains_key(session_id) {
// shutdown_active drops the ActiveSession (and its terminal PTY).
self.shutdown_active(session_id, cx);
let mut ids = descendant_session_ids(&self.sessions, session_id);
if ids.is_empty() {
ids.push(session_id.to_string());
}
self.delete_session_ids(&ids, remove_worktree, cx);
}

fn delete_session_ids(&mut self, ids: &[String], remove_worktree: bool, cx: &mut HostCx) {
if ids.is_empty() {
return;
}
let deleted: HashSet<&str> = ids.iter().map(String::as_str).collect();
let metas: Vec<_> = self
.sessions
.iter()
.filter(|meta| deleted.contains(meta.id.as_str()))
.cloned()
.collect();
let mut terminal_preferences_changed = false;
for id in ids {
self.clear_approvals(id);
if self.residents.live.contains_key(id) {
// shutdown_active drops the ActiveSession (and its terminal PTY).
self.shutdown_active(id, cx);
}
// Deleting a thread that is working in the background kills it for real.
self.drop_background(id, cx);
self.terminal_workspaces
.remove(&ConversationDestination::Thread(id.clone()));
terminal_preferences_changed |= self.terminal_preferences.remove(id).is_some();
self.revoke_orchestrate_child_registration(id);
self.close_orchestrator_children(id, cx);
self.settings.last_visited.remove(id);
}
// Deleting a thread that is working in the background kills it for real.
self.drop_background(session_id, cx);
self.terminal_workspaces
.remove(&ConversationDestination::Thread(session_id.to_string()));
if self.terminal_preferences.remove(session_id).is_some() {
if terminal_preferences_changed {
self.write_terminal_preferences(cx);
}
self.close_orchestrator_children(session_id, cx);
let worktree_remove = meta.as_ref().and_then(|meta| {
let worktree = meta.worktree.as_ref().filter(|_| remove_worktree)?;
let shared = self.worktree_sharing().is_shared(meta);
if shared {
self.settings
.collapsed_threads
.retain(|id| !deleted.contains(id.as_str()));
if metas
.iter()
.any(|meta| meta.archived_at.is_some() || self.archived_sharing_affected(meta))
{
self.archived_revision += 1;
}
self.sessions
.retain(|meta| !deleted.contains(meta.id.as_str()));
let mut kept_worktrees = Vec::new();
let mut worktree_removals = Vec::new();
let sharing = self.worktree_sharing();
for meta in &metas {
let Some(worktree) = &meta.worktree else {
continue;
};
if !remove_worktree {
kept_worktrees.push(meta.cwd.clone());
} else if sharing.is_shared(meta) {
log::info!(
"keeping worktree {} in use by another thread",
meta.cwd.display()
);
return None;
} else {
worktree_removals.push((
meta.id.clone(),
worktree.root_project_path.clone(),
meta.cwd.clone(),
));
}
Some((worktree.root_project_path.clone(), meta.cwd.clone()))
});
self.settings.last_visited.remove(session_id);
self.settings
.collapsed_threads
.retain(|id| id != session_id);
self.enqueue_store_write(StoreWrite::RemoveSession(session_id.to_string()), cx);
}
self.enqueue_store_write(
StoreWrite::RemoveSessions {
ids: ids.to_vec(),
kept_worktrees,
},
cx,
);
// Persist the pruned last-visited map (ignore save errors — cosmetic).
self.persist_settings(cx);
if meta
.as_ref()
.is_some_and(|meta| meta.archived_at.is_some() || self.archived_sharing_affected(meta))
{
self.archived_revision += 1;
}
self.sessions.retain(|meta| meta.id != session_id);
if let Some((root, cwd)) = worktree_remove {
let deleted_id = session_id.to_string();
for (deleted_id, root, cwd) in worktree_removals {
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let result = host_cx
Expand Down Expand Up @@ -1083,9 +1137,19 @@ impl AppState {
{
self.write_terminal_preferences(cx);
}
let mut ids = Vec::new();
let mut seen = HashSet::new();
for session_id in session_ids {
self.delete_session(&session_id, false, cx);
if seen.contains(&session_id) {
continue;
}
for id in descendant_session_ids(&self.sessions, &session_id) {
if seen.insert(id.clone()) {
ids.push(id);
}
}
}
self.delete_session_ids(&ids, false, cx);
self.enqueue_store_write(StoreWrite::RemoveProject(project_id.to_string()), cx);
self.settings
.collapsed_projects
Expand Down Expand Up @@ -1144,24 +1208,13 @@ impl AppState {
.is_some_and(|&visited| meta.updated_at > visited)
}

/// Remove app-owned worktrees that no session in the loaded store owns or
/// works in.
/// Remove app-owned worktrees that no thread in the store works in and the
/// user did not keep.
pub(crate) fn recover_orphaned_worktrees(&self, cx: &mut HostCx) {
let known_ids = self
.sessions
.iter()
.map(|session| session.id.clone())
.collect();
let cwds: Vec<_> = self
.sessions
.iter()
.map(|session| session.cwd.clone())
.collect();
let store = self.store.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let summary = host_cx
.unblock(move || cleanup_orphans(&known_ids, &cwds))
.await;
let summary = host_cx.unblock(move || cleanup_orphans(&store)).await;
if !summary.removed.is_empty() || !summary.skipped.is_empty() {
log::info!(
"worktree orphan recovery removed {}, left {}",
Expand Down Expand Up @@ -1199,12 +1252,13 @@ impl AppState {
let root = active.meta.cwd.clone();

let root_for_task = root.clone();
let data_dir = self.store.root().clone();
let target_id = target_id.to_string();
let delivery_key = cx.delivery_key.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
let result = host_cx
.unblock(move || provision(&root_for_task, &session_id_for_task))
.unblock(move || provision(&root_for_task, &session_id_for_task, &data_dir))
.await;
host_cx.enqueue(move |state, cx| {
if state
Expand Down Expand Up @@ -1652,32 +1706,3 @@ impl AppState {
self.refresh_git_status(&session_id, cx);
}
}

pub(super) fn descendant_session_ids(sessions: &[SessionMeta], root_id: &str) -> Vec<String> {
fn append(
sessions: &[SessionMeta],
session_id: &str,
visited: &mut HashSet<String>,
output: &mut Vec<String>,
) {
if !visited.insert(session_id.to_string()) {
return;
}
output.push(session_id.to_string());
let children: Vec<_> = sessions
.iter()
.filter(|meta| meta.parent_session_id.as_deref() == Some(session_id))
.map(|meta| meta.id.clone())
.collect();
for child in children {
append(sessions, &child, visited, output);
}
}

if !sessions.iter().any(|meta| meta.id == root_id) {
return Vec::new();
}
let mut output = Vec::new();
append(sessions, root_id, &mut HashSet::new(), &mut output);
output
}
Loading
Loading