From 3edfe9d3dc053350f7b0b7792eeefd6d24365192 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Tue, 31 Mar 2026 21:38:00 +0800 Subject: [PATCH] fix: hide internal subagent sessions from user history - add explicit session kind metadata for persisted sessions and summaries - filter subagent and legacy leaked internal sessions from session APIs and visible lists - run workspace session maintenance on restore/open to clean hidden session records - sync desktop and web session metadata types and add regression tests --- src/apps/desktop/src/api/session_api.rs | 6 +- .../src/agentic/coordination/coordinator.rs | 12 +- src/crates/core/src/agentic/core/mod.rs | 2 +- src/crates/core/src/agentic/core/session.rs | 14 ++ .../core/src/agentic/persistence/manager.rs | 133 +++++++++-- .../core/src/agentic/persistence/mod.rs | 4 + .../session_workspace_maintenance.rs | 217 ++++++++++++++++++ .../src/agentic/session/session_manager.rs | 60 ++++- src/crates/core/src/service/session/mod.rs | 1 + src/crates/core/src/service/session/types.rs | 92 +++++++- .../core/src/service/workspace/service.rs | 100 ++++++-- .../src/shared/types/session-history.ts | 2 + 12 files changed, 597 insertions(+), 46 deletions(-) create mode 100644 src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 574bd9b5..e1461d18 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -290,8 +290,10 @@ pub async fn load_persisted_session_metadata( let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - manager + let metadata = manager .load_session_metadata(&workspace_path, &request.session_id) .await - .map_err(|e| format!("Failed to load persisted session metadata: {}", e)) + .map_err(|e| format!("Failed to load persisted session metadata: {}", e))?; + + Ok(metadata.filter(|metadata| !metadata.should_hide_from_user_lists())) } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 51ee6587..bdea500d 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -6,7 +6,7 @@ use super::{scheduler::DialogSubmissionPolicy, turn_outcome::TurnOutcome}; use crate::agentic::agents::get_agent_registry; use crate::agentic::core::{ has_prompt_markup, Message, MessageContent, ProcessingPhase, PromptEnvelope, Session, - SessionConfig, SessionState, SessionSummary, TurnStats, + SessionConfig, SessionKind, SessionState, SessionSummary, TurnStats, }; use crate::agentic::events::{ AgenticEvent, EventPriority, EventQueue, EventRouter, EventSubscriber, @@ -625,6 +625,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_name: "Recovered Session".to_string(), agent_type: "agentic".to_string(), created_by: None, + session_kind: SessionKind::Standard, model_name: "default".to_string(), created_at: now_ms, last_active_at: now_ms, @@ -688,15 +689,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_name: String, agent_type: String, config: SessionConfig, - creator_session_id: Option<&str>, + parent_info: &SubagentParentInfo, ) -> BitFunResult { self.session_manager - .create_session_with_id_and_creator( + .create_session_with_id_and_details( None, session_name, agent_type, config, - creator_session_id.map(|session_id| format!("session-{}", session_id)), + Some(format!("session-{}", parent_info.session_id)), + SessionKind::Subagent, ) .await } @@ -1884,7 +1886,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet format!("Subagent: {}", task_description), agent_type.clone(), subagent_config, - Some(&subagent_parent_info.session_id), + &subagent_parent_info, ) .await?; diff --git a/src/crates/core/src/agentic/core/mod.rs b/src/crates/core/src/agentic/core/mod.rs index 5bd0c55f..ad8b6a55 100644 --- a/src/crates/core/src/agentic/core/mod.rs +++ b/src/crates/core/src/agentic/core/mod.rs @@ -21,5 +21,5 @@ pub use prompt_markup::{ has_prompt_markup, is_system_reminder_only, render_system_reminder, render_user_query, strip_prompt_markup, PromptBlock, PromptBlockKind, PromptEnvelope, }; -pub use session::{CompressionState, Session, SessionConfig, SessionSummary}; +pub use session::{CompressionState, Session, SessionConfig, SessionKind, SessionSummary}; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 015ce06d..7523d81b 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -3,6 +3,14 @@ use serde::{Deserialize, Serialize}; use std::time::SystemTime; use uuid::Uuid; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SessionKind { + #[default] + Standard, + Subagent, +} + // ============ Session ============ /// Session: contains multiple dialog turns @@ -18,6 +26,8 @@ pub struct Session { alias = "createdBy" )] pub created_by: Option, + #[serde(default, alias = "session_kind", alias = "sessionKind")] + pub kind: SessionKind, /// Associated resources #[serde( @@ -78,6 +88,7 @@ impl Session { session_name, agent_type, created_by: None, + kind: SessionKind::Standard, snapshot_session_id: None, dialog_turn_ids: vec![], state: SessionState::Idle, @@ -101,6 +112,7 @@ impl Session { session_name, agent_type, created_by: None, + kind: SessionKind::Standard, snapshot_session_id: None, dialog_turn_ids: vec![], state: SessionState::Idle, @@ -173,6 +185,8 @@ pub struct SessionSummary { alias = "createdBy" )] pub created_by: Option, + #[serde(default, alias = "session_kind", alias = "sessionKind")] + pub kind: SessionKind, pub turn_count: usize, pub created_at: SystemTime, pub last_activity_at: SystemTime, diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index adbad0b2..0e4ced62 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -6,10 +6,10 @@ use crate::agentic::core::{ strip_prompt_markup, CompressionState, Message, MessageContent, Session, SessionConfig, SessionState, SessionSummary, }; +use crate::infrastructure::PathManager; use crate::service::remote_ssh::workspace_state::{ resolve_workspace_session_identity, LOCAL_WORKSPACE_SSH_HOST, }; -use crate::infrastructure::PathManager; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionStatus, SessionTranscriptExport, SessionTranscriptExportOptions, SessionTranscriptIndexEntry, ToolItemData, TranscriptLineRange, @@ -581,18 +581,17 @@ impl PersistenceManager { .or_else(|| existing.map(|value| value.model_name.clone())) .unwrap_or_else(|| "default".to_string()); - let resolved_identity = if let Some(workspace_root) = - session.config.workspace_path.as_deref() - { - resolve_workspace_session_identity( - workspace_root, - session.config.remote_connection_id.as_deref(), - session.config.remote_ssh_host.as_deref(), - ) - .await - } else { - None - }; + let resolved_identity = + if let Some(workspace_root) = session.config.workspace_path.as_deref() { + resolve_workspace_session_identity( + workspace_root, + session.config.remote_connection_id.as_deref(), + session.config.remote_ssh_host.as_deref(), + ) + .await + } else { + None + }; let workspace_root = resolved_identity .as_ref() @@ -620,6 +619,7 @@ impl PersistenceManager { .created_by .clone() .or_else(|| existing.and_then(|value| value.created_by.clone())), + session_kind: session.kind, model_name, created_at, last_active_at, @@ -1161,7 +1161,7 @@ impl PersistenceManager { } } - async fn rebuild_index_locked( + async fn scan_session_metadata_dirs( &self, workspace_path: &Path, ) -> BitFunResult> { @@ -1200,15 +1200,28 @@ impl PersistenceManager { metadata_list.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); + Ok(metadata_list) + } + + async fn rebuild_index_locked( + &self, + workspace_path: &Path, + ) -> BitFunResult> { + let metadata_list = self.scan_session_metadata_dirs(workspace_path).await?; + let visible_sessions = metadata_list + .into_iter() + .filter(|metadata| !metadata.should_hide_from_user_lists()) + .collect::>(); + let index = StoredSessionIndex { schema_version: SESSION_SCHEMA_VERSION, updated_at: Self::system_time_to_unix_ms(SystemTime::now()), - sessions: metadata_list.clone(), + sessions: visible_sessions.clone(), }; self.write_json_atomic(&self.index_path(workspace_path), &index) .await?; - Ok(metadata_list) + Ok(visible_sessions) } async fn upsert_index_entry_locked( @@ -1319,6 +1332,17 @@ impl PersistenceManager { self.rebuild_index_locked(workspace_path).await } + pub async fn list_session_metadata_including_internal( + &self, + workspace_path: &Path, + ) -> BitFunResult> { + if !workspace_path.exists() { + return Ok(Vec::new()); + } + + self.scan_session_metadata_dirs(workspace_path).await + } + pub async fn save_session_metadata( &self, workspace_path: &Path, @@ -1337,7 +1361,12 @@ impl PersistenceManager { &file, ) .await?; - self.upsert_index_entry(workspace_path, metadata).await + if !metadata.should_hide_from_user_lists() { + self.upsert_index_entry(workspace_path, metadata).await + } else { + self.remove_index_entry(workspace_path, &metadata.session_id) + .await + } } pub async fn load_session_metadata( @@ -1581,6 +1610,7 @@ impl PersistenceManager { session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), created_by: metadata.created_by.clone(), + kind: metadata.session_kind, snapshot_session_id: stored_state .and_then(|value| value.snapshot_session_id) .or(metadata.snapshot_session_id.clone()), @@ -1655,6 +1685,7 @@ impl PersistenceManager { session_name: metadata.session_name, agent_type: metadata.agent_type, created_by: metadata.created_by, + kind: metadata.session_kind, turn_count: metadata.turn_count, created_at: Self::unix_ms_to_system_time(metadata.created_at), last_activity_at: Self::unix_ms_to_system_time(metadata.last_active_at), @@ -2087,12 +2118,12 @@ impl PersistenceManager { } Ok(()) } - } #[cfg(test)] mod tests { use super::PersistenceManager; + use crate::agentic::core::SessionKind; use crate::infrastructure::PathManager; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExportOptions, UserMessageData, @@ -2212,4 +2243,70 @@ mod tests { assert!(transcript.contains("## Turn 0")); assert!(transcript.contains("hello transcript")); } + + #[tokio::test] + async fn subagent_session_kind_is_hidden_from_visible_session_index() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + + let mut metadata = SessionMetadata::new( + Uuid::new_v4().to_string(), + "Subagent: repo sweep".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.session_kind = SessionKind::Subagent; + + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + + let visible = manager + .list_session_metadata(workspace.path()) + .await + .expect("visible metadata should load"); + let raw = manager + .list_session_metadata_including_internal(workspace.path()) + .await + .expect("raw metadata should load"); + + assert!(visible.is_empty()); + assert_eq!(raw.len(), 1); + assert!(raw[0].is_subagent()); + } + + #[tokio::test] + async fn legacy_leaked_subagent_is_hidden_from_visible_session_index() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + + let mut metadata = SessionMetadata::new( + Uuid::new_v4().to_string(), + "Subagent: stale task".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.created_by = Some("session-parent".to_string()); + + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + + let visible = manager + .list_session_metadata(workspace.path()) + .await + .expect("visible metadata should load"); + let raw = manager + .list_session_metadata_including_internal(workspace.path()) + .await + .expect("raw metadata should load"); + + assert!(visible.is_empty()); + assert_eq!(raw.len(), 1); + assert!(raw[0].is_legacy_leaked_subagent_candidate()); + } } diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs index e60f7a01..15734a24 100644 --- a/src/crates/core/src/agentic/persistence/mod.rs +++ b/src/crates/core/src/agentic/persistence/mod.rs @@ -3,5 +3,9 @@ //! Responsible for persistent storage and loading of data pub mod manager; +pub mod session_workspace_maintenance; pub use manager::PersistenceManager; +pub use session_workspace_maintenance::{ + SessionWorkspaceMaintenanceReport, SessionWorkspaceMaintenanceService, +}; diff --git a/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs b/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs new file mode 100644 index 00000000..9a2dcc35 --- /dev/null +++ b/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs @@ -0,0 +1,217 @@ +use super::PersistenceManager; +use crate::util::errors::BitFunResult; +use dashmap::{DashMap, DashSet}; +use log::info; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionWorkspaceMaintenanceReport { + pub scanned_sessions: usize, + pub hidden_sessions: usize, + pub deleted_sessions: usize, + pub skipped: bool, +} + +pub struct SessionWorkspaceMaintenanceService { + persistence_manager: Arc, + cleaned_workspaces: DashSet, + workspace_locks: DashMap>>, +} + +impl SessionWorkspaceMaintenanceService { + pub fn new(persistence_manager: Arc) -> Self { + Self { + persistence_manager, + cleaned_workspaces: DashSet::new(), + workspace_locks: DashMap::new(), + } + } + + pub async fn ensure_workspace_maintained( + &self, + workspace_path: &Path, + ) -> BitFunResult { + let workspace_key = workspace_path.to_path_buf(); + + if self.cleaned_workspaces.contains(&workspace_key) { + return Ok(SessionWorkspaceMaintenanceReport { + skipped: true, + ..Default::default() + }); + } + + let workspace_lock = self + .workspace_locks + .entry(workspace_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = workspace_lock.lock().await; + + if self.cleaned_workspaces.contains(&workspace_key) { + return Ok(SessionWorkspaceMaintenanceReport { + skipped: true, + ..Default::default() + }); + } + + let report = self.run_workspace_maintenance(workspace_path).await?; + self.cleaned_workspaces.insert(workspace_key); + + Ok(report) + } + + async fn run_workspace_maintenance( + &self, + workspace_path: &Path, + ) -> BitFunResult { + if !workspace_path.exists() { + return Ok(SessionWorkspaceMaintenanceReport::default()); + } + + let all_metadata = self + .persistence_manager + .list_session_metadata_including_internal(workspace_path) + .await?; + let hidden_session_ids = all_metadata + .iter() + .filter(|metadata| metadata.should_hide_from_user_lists()) + .map(|metadata| metadata.session_id.clone()) + .collect::>(); + + let mut report = SessionWorkspaceMaintenanceReport { + scanned_sessions: all_metadata.len(), + hidden_sessions: hidden_session_ids.len(), + deleted_sessions: 0, + skipped: false, + }; + + for session_id in hidden_session_ids { + self.persistence_manager + .delete_session(workspace_path, &session_id) + .await?; + report.deleted_sessions += 1; + } + + if report.deleted_sessions > 0 { + info!( + "Workspace session maintenance removed hidden sessions: workspace_path={}, scanned_sessions={}, hidden_sessions={}, deleted_sessions={}", + workspace_path.display(), + report.scanned_sessions, + report.hidden_sessions, + report.deleted_sessions + ); + } + + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::SessionWorkspaceMaintenanceService; + use crate::agentic::core::SessionKind; + use crate::agentic::persistence::PersistenceManager; + use crate::infrastructure::PathManager; + use crate::service::session::SessionMetadata; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "bitfun-session-maintenance-test-{}", + Uuid::new_v4() + )); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[tokio::test] + async fn workspace_maintenance_removes_hidden_sessions_once() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let maintenance = SessionWorkspaceMaintenanceService::new(persistence_manager.clone()); + + let visible = SessionMetadata::new( + Uuid::new_v4().to_string(), + "Visible Session".to_string(), + "agent".to_string(), + "model".to_string(), + ); + + let mut legacy_hidden = SessionMetadata::new( + Uuid::new_v4().to_string(), + "Subagent: stale task".to_string(), + "agent".to_string(), + "model".to_string(), + ); + legacy_hidden.created_by = Some("session-parent".to_string()); + + let mut subagent_hidden = SessionMetadata::new( + Uuid::new_v4().to_string(), + "Subagent: fresh task".to_string(), + "agent".to_string(), + "model".to_string(), + ); + subagent_hidden.session_kind = SessionKind::Subagent; + + for metadata in [&visible, &legacy_hidden, &subagent_hidden] { + persistence_manager + .save_session_metadata(workspace.path(), metadata) + .await + .expect("metadata should save"); + } + + let first_report = maintenance + .ensure_workspace_maintained(workspace.path()) + .await + .expect("maintenance should succeed"); + + assert_eq!(first_report.scanned_sessions, 3); + assert_eq!(first_report.hidden_sessions, 2); + assert_eq!(first_report.deleted_sessions, 2); + assert!(!first_report.skipped); + + let raw_after_cleanup = persistence_manager + .list_session_metadata_including_internal(workspace.path()) + .await + .expect("raw metadata should load"); + assert_eq!(raw_after_cleanup.len(), 1); + assert_eq!(raw_after_cleanup[0].session_id, visible.session_id); + + let visible_after_cleanup = persistence_manager + .list_session_metadata(workspace.path()) + .await + .expect("visible metadata should load"); + assert_eq!(visible_after_cleanup.len(), 1); + assert_eq!(visible_after_cleanup[0].session_id, visible.session_id); + + let second_report = maintenance + .ensure_workspace_maintained(workspace.path()) + .await + .expect("second maintenance should succeed"); + assert!(second_report.skipped); + assert_eq!(second_report.deleted_sessions, 0); + } +} diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index abaa5dd2..e02c00a7 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -4,7 +4,7 @@ use crate::agentic::core::{ CompressionState, DialogTurn, Message, MessageSemanticKind, ProcessingPhase, Session, - SessionConfig, SessionState, SessionSummary, TurnStats, + SessionConfig, SessionKind, SessionState, SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; @@ -390,8 +390,15 @@ impl SessionManager { agent_type: String, config: SessionConfig, ) -> BitFunResult { - self.create_session_with_id_and_creator(None, session_name, agent_type, config, None) - .await + self.create_session_with_id_and_details( + None, + session_name, + agent_type, + config, + None, + SessionKind::Standard, + ) + .await } /// Create a new session (supports specifying session ID) @@ -402,8 +409,15 @@ impl SessionManager { agent_type: String, config: SessionConfig, ) -> BitFunResult { - self.create_session_with_id_and_creator(session_id, session_name, agent_type, config, None) - .await + self.create_session_with_id_and_details( + session_id, + session_name, + agent_type, + config, + None, + SessionKind::Standard, + ) + .await } /// Create a new session (supports specifying session ID and creator identity) @@ -414,6 +428,27 @@ impl SessionManager { agent_type: String, config: SessionConfig, created_by: Option, + ) -> BitFunResult { + self.create_session_with_id_and_details( + session_id, + session_name, + agent_type, + config, + created_by, + SessionKind::Standard, + ) + .await + } + + /// Create a new session with explicit kind. + pub async fn create_session_with_id_and_details( + &self, + session_id: Option, + session_name: String, + agent_type: String, + config: SessionConfig, + created_by: Option, + kind: SessionKind, ) -> BitFunResult { let _workspace_path = Self::session_workspace_from_config(&config).ok_or_else(|| { BitFunError::Validation("Session workspace_path is required".to_string()) @@ -439,6 +474,7 @@ impl SessionManager { Session::new(session_name, agent_type.clone(), config) }; session.created_by = created_by; + session.kind = kind; let session_id = session.session_id.clone(); // 1. Add to memory @@ -743,6 +779,18 @@ impl SessionManager { .unwrap_or_else(|| workspace_path.to_path_buf()) }; + if self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + // 1. Load session from storage let mut session = self .persistence_manager @@ -947,12 +995,14 @@ impl SessionManager { session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), created_by: session.created_by.clone(), + kind: session.kind, turn_count: session.dialog_turn_ids.len(), created_at: session.created_at, last_activity_at: session.last_activity_at, state: session.state.clone(), } }) + .filter(|summary| !matches!(summary.kind, SessionKind::Subagent)) .collect(); Ok(summaries) } diff --git a/src/crates/core/src/service/session/mod.rs b/src/crates/core/src/service/session/mod.rs index e1aed5ed..45d4ed3c 100644 --- a/src/crates/core/src/service/session/mod.rs +++ b/src/crates/core/src/service/session/mod.rs @@ -2,4 +2,5 @@ pub mod types; +pub use crate::agentic::core::SessionKind; pub use types::*; diff --git a/src/crates/core/src/service/session/types.rs b/src/crates/core/src/service/session/types.rs index bc905d56..d76ae6c1 100644 --- a/src/crates/core/src/service/session/types.rs +++ b/src/crates/core/src/service/session/types.rs @@ -1,5 +1,6 @@ //! Types for session persistence +use crate::agentic::core::SessionKind; use serde::{Deserialize, Serialize}; /// Session metadata @@ -21,6 +22,8 @@ pub struct SessionMetadata { /// Creator identity for future permission checks #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by")] pub created_by: Option, + #[serde(default, alias = "session_kind", alias = "sessionKind")] + pub session_kind: SessionKind, /// Model name #[serde(alias = "model_name")] @@ -79,7 +82,11 @@ pub struct SessionMetadata { /// Unified hostname for workspace identity: `localhost` for local workspaces, /// SSH host for remote workspaces. - #[serde(default, skip_serializing_if = "Option::is_none", alias = "workspace_hostname")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "workspace_hostname" + )] pub workspace_hostname: Option, } @@ -458,6 +465,7 @@ impl SessionMetadata { session_name, agent_type, created_by: None, + session_kind: SessionKind::Standard, model_name, created_at: now, last_active_at: now, @@ -497,6 +505,29 @@ impl SessionMetadata { pub fn add_tool_calls(&mut self, count: usize) { self.tool_call_count += count; } + + pub fn is_subagent(&self) -> bool { + matches!(self.session_kind, SessionKind::Subagent) + } + + pub fn is_standard(&self) -> bool { + !self.is_subagent() + } + + pub fn is_legacy_leaked_subagent_candidate(&self) -> bool { + let Some(created_by) = self.created_by.as_deref() else { + return false; + }; + if !created_by.starts_with("session-") { + return false; + } + + self.session_name.starts_with("Subagent: ") + } + + pub fn should_hide_from_user_lists(&self) -> bool { + self.is_subagent() || self.is_legacy_leaked_subagent_candidate() + } } impl DialogTurnData { @@ -567,7 +598,8 @@ impl DialogTurnData { #[cfg(test)] mod tests { - use super::{DialogTurnData, DialogTurnKind, UserMessageData}; + use super::{DialogTurnData, DialogTurnKind, SessionMetadata, UserMessageData}; + use crate::agentic::core::SessionKind; #[test] fn dialog_turn_kind_defaults_to_user_dialog_for_legacy_payloads() { @@ -608,4 +640,60 @@ mod tests { assert_eq!(turn.kind, DialogTurnKind::UserDialog); } + + #[test] + fn session_metadata_marks_explicit_subagent_as_non_standard() { + let mut metadata = SessionMetadata::new( + "session-1".to_string(), + "Subagent: explore repo".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.session_kind = SessionKind::Subagent; + + assert!(metadata.is_subagent()); + assert!(!metadata.is_standard()); + } + + #[test] + fn session_metadata_does_not_treat_standard_session_as_subagent_from_name_or_creator() { + let mut metadata = SessionMetadata::new( + "session-1".to_string(), + "Subagent: repo sweep".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.created_by = Some("session-parent".to_string()); + + assert!(!metadata.is_subagent()); + assert!(metadata.is_standard()); + } + + #[test] + fn session_metadata_detects_legacy_leaked_subagent_candidate() { + let mut metadata = SessionMetadata::new( + "session-1".to_string(), + "Subagent: repo sweep".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.created_by = Some("session-parent".to_string()); + + assert!(!metadata.is_subagent()); + assert!(metadata.is_legacy_leaked_subagent_candidate()); + assert!(metadata.should_hide_from_user_lists()); + } + + #[test] + fn session_metadata_keeps_normal_sessions_visible() { + let metadata = SessionMetadata::new( + "session-1".to_string(), + "Normal Session".to_string(), + "agentic".to_string(), + "model".to_string(), + ); + + assert!(!metadata.is_subagent()); + assert!(metadata.is_standard()); + } } diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index a14b14da..f32af8a7 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -7,6 +7,7 @@ use super::manager::{ WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; +use crate::agentic::persistence::{PersistenceManager, SessionWorkspaceMaintenanceService}; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::service::bootstrap::initialize_workspace_persona_files; @@ -28,6 +29,7 @@ pub struct WorkspaceService { config: WorkspaceManagerConfig, persistence: Arc, path_manager: Arc, + session_workspace_maintenance: Arc, } /// Workspace creation options. @@ -94,6 +96,34 @@ struct AssistantWorkspaceDescriptor { } impl WorkspaceService { + async fn maintain_workspace_sessions_best_effort(&self, workspace_path: &Path, trigger: &str) { + match self + .session_workspace_maintenance + .ensure_workspace_maintained(workspace_path) + .await + { + Ok(report) if report.skipped || report.deleted_sessions == 0 => {} + Ok(report) => { + info!( + "Workspace session maintenance finished: trigger={}, workspace_path={}, scanned_sessions={}, hidden_sessions={}, deleted_sessions={}", + trigger, + workspace_path.display(), + report.scanned_sessions, + report.hidden_sessions, + report.deleted_sessions + ); + } + Err(e) => { + warn!( + "Failed to maintain workspace sessions: trigger={}, workspace_path={}, error={}", + trigger, + workspace_path.display(), + e + ); + } + } + } + /// Creates a new workspace service. pub async fn new() -> BitFunResult { let config = WorkspaceManagerConfig::default(); @@ -115,12 +145,16 @@ impl WorkspaceService { ); let manager = WorkspaceManager::new(config.clone()); + let session_workspace_maintenance = Arc::new(SessionWorkspaceMaintenanceService::new( + Arc::new(PersistenceManager::new(path_manager.clone())?), + )); let service = Self { manager: Arc::new(RwLock::new(manager)), config, persistence, path_manager, + session_workspace_maintenance, }; if let Err(e) = service.load_workspace_history_only().await { @@ -177,6 +211,11 @@ impl WorkspaceService { } } + if let Ok(workspace) = result.as_ref() { + self.maintain_workspace_sessions_best_effort(&workspace.root_path, "workspace_opened") + .await; + } + result } @@ -306,6 +345,16 @@ impl WorkspaceService { } } + if result.is_ok() { + if let Some(workspace) = self.get_workspace(workspace_id).await { + self.maintain_workspace_sessions_best_effort( + &workspace.root_path, + "workspace_activated", + ) + .await; + } + } + result } @@ -1022,10 +1071,7 @@ impl WorkspaceService { let id_remap = manager.migrate_local_workspace_ids_to_stable_storage(); if let Some(raw_current) = data.current_workspace_id { - let current_id = id_remap - .get(&raw_current) - .cloned() - .unwrap_or(raw_current); + let current_id = id_remap.get(&raw_current).cloned().unwrap_or(raw_current); if let Some(workspace) = manager.get_workspaces().get(¤t_id) { if workspace.is_valid().await { if let Err(e) = manager.set_current_workspace(current_id) { @@ -1056,6 +1102,8 @@ impl WorkspaceService { .await .map_err(|e| BitFunError::service(format!("Failed to load workspace data: {}", e)))?; + let mut workspace_to_maintain: Option = None; + if let Some(data) = workspace_data { let mut manager = self.manager.write().await; @@ -1077,15 +1125,30 @@ impl WorkspaceService { *manager.get_workspaces_mut() = workspaces; // Also filter opened/recent lists to remove references to removed legacy workspaces - let filtered_opened_ids: Vec = data.opened_workspace_ids.clone().into_iter().filter(|id| manager.get_workspaces().contains_key(id)).collect(); + let filtered_opened_ids: Vec = data + .opened_workspace_ids + .clone() + .into_iter() + .filter(|id| manager.get_workspaces().contains_key(id)) + .collect(); manager.set_opened_workspace_ids(filtered_opened_ids); - - let filtered_recent: Vec = data.recent_workspaces.clone().into_iter().filter(|id| manager.get_workspaces().contains_key(id)).collect(); + + let filtered_recent: Vec = data + .recent_workspaces + .clone() + .into_iter() + .filter(|id| manager.get_workspaces().contains_key(id)) + .collect(); manager.set_recent_workspaces(filtered_recent); - - let filtered_recent_assistant: Vec = data.recent_assistant_workspaces.clone().into_iter().filter(|id| manager.get_workspaces().contains_key(id)).collect(); + + let filtered_recent_assistant: Vec = data + .recent_assistant_workspaces + .clone() + .into_iter() + .filter(|id| manager.get_workspaces().contains_key(id)) + .collect(); manager.set_recent_assistant_workspaces(filtered_recent_assistant); - + let id_remap = manager.migrate_local_workspace_ids_to_stable_storage(); let raw_current = data @@ -1097,11 +1160,23 @@ impl WorkspaceService { if manager.get_workspaces().contains_key(¤t_id) { if let Err(e) = manager.set_current_workspace(current_id) { warn!("Failed to restore current workspace on startup: {}", e); + } else { + workspace_to_maintain = manager + .get_current_workspace() + .map(|workspace| workspace.root_path.clone()); } } } } + if let Some(workspace_path) = workspace_to_maintain { + self.maintain_workspace_sessions_best_effort( + &workspace_path, + "workspace_history_restored", + ) + .await; + } + Ok(()) } @@ -1505,9 +1580,8 @@ impl WorkspaceService { // If a remote workspace tab exists but nothing is current yet (e.g. pending SSH // reconnect), do not auto-activate the default assistant workspace — that would look // like a spurious new local workspace. - let should_activate = !has_current_workspace - && !has_opened_remote - && descriptor.assistant_id.is_none(); + let should_activate = + !has_current_workspace && !has_opened_remote && descriptor.assistant_id.is_none(); let options = WorkspaceCreateOptions { auto_set_current: should_activate, add_to_recent: false, diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 15df8ccb..27664c05 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -5,6 +5,7 @@ */ export type SessionKind = 'normal' | 'btw'; +export type PersistedSessionKind = 'standard' | 'subagent'; export interface SessionCustomMetadata extends Record { kind?: SessionKind; @@ -19,6 +20,7 @@ export interface SessionMetadata { sessionId: string; sessionName: string; agentType: string; + sessionKind?: PersistedSessionKind; modelName: string; createdAt: number; lastActiveAt: number;