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
19 changes: 19 additions & 0 deletions src/apps/cli/src/chat_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,10 @@ impl ChatState {
.is_some()
}

pub(crate) fn has_conversation_history(&self) -> bool {
self.metadata.message_count > 0
}

pub(crate) fn set_worktree_control_available(&mut self, available: bool) {
self.worktree_control_available = available;
}
Expand Down Expand Up @@ -1402,6 +1406,21 @@ mod tests {
);
}

#[test]
fn worktree_binding_history_ignores_local_system_messages() {
let mut state = ChatState::new(
"session-1".to_string(),
"Session".to_string(),
"agentic".to_string(),
Some("/tmp/project".to_string()),
);
state.add_system_message("Worktree: off".to_string());
assert!(!state.has_conversation_history());

state.metadata.message_count = 1;
assert!(state.has_conversation_history());
}

#[test]
fn workspace_context_prefers_managed_worktree_branch_or_detached_commit() {
let mut state = ChatState::new(
Expand Down
38 changes: 24 additions & 14 deletions src/apps/cli/src/modes/chat/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ impl ChatMode {
chat_state: &mut ChatState,
rt_handle: &tokio::runtime::Handle,
) -> Result<Option<ChatExitReason>> {
let command = match parse_worktree_command(arguments) {
Ok(command) => command,
Err(usage) => {
chat_view.set_status(Some(usage.clone()));
chat_state.add_system_message(usage);
return Ok(None);
}
};

self.refresh_workspace_git_status(chat_state, rt_handle);
if command == WorktreeCommand::Status {
let message = Self::worktree_status_message(chat_state);
chat_view.set_status(Some(chat_state.workspace_context_label()));
chat_state.add_system_message(message);
return Ok(None);
}

let action = action_by_id("toggle_worktree", ActionContext::Chat)
.expect("Worktree action must remain registered");
let state = ActionState::chat(chat_state.is_processing, false);
Expand All @@ -85,20 +102,11 @@ impl ChatMode {
chat_state.add_system_message(message);
return Ok(None);
}

let command = match parse_worktree_command(arguments) {
Ok(command) => command,
Err(usage) => {
chat_view.set_status(Some(usage.clone()));
chat_state.add_system_message(usage);
return Ok(None);
}
};

self.refresh_workspace_git_status(chat_state, rt_handle);
if command == WorktreeCommand::Status {
let message = Self::worktree_status_message(chat_state);
chat_view.set_status(Some(chat_state.workspace_context_label()));
if chat_state.has_conversation_history() {
let message =
"Worktree isolation can only be changed before the session's first message"
.to_string();
chat_view.set_status(Some(message.clone()));
chat_state.add_system_message(message);
return Ok(None);
}
Expand All @@ -114,11 +122,13 @@ impl ChatMode {
"Disabling worktree isolation...".to_string()
}));

let project_workspace_path = chat_state.project_workspace_path().map(str::to_string);
let result = tokio::task::block_in_place(|| {
rt_handle.block_on(WorktreeService::bind_session(
WorktreeSessionBindingRequest {
request_id: uuid::Uuid::new_v4().to_string(),
session_id: chat_state.core_session_id.clone(),
project_workspace_path,
enabled,
},
))
Expand Down
5 changes: 3 additions & 2 deletions src/apps/desktop/src/api/worktree_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ pub async fn worktree_remove(
WorktreeService::remove(request).await
}

/// Toggle worktree isolation for a single session. The project path is derived
/// from the session itself, so remote checks live in the product layer.
/// Toggle worktree isolation for a single session. The optional project path
/// lets the product layer locate view-only persisted sessions; remote checks
/// and repository resolution remain in that shared layer.
#[tauri::command]
pub async fn worktree_bind_session(
request: WorktreeSessionBindingRequest,
Expand Down
153 changes: 141 additions & 12 deletions src/crates/assembly/core/src/agentic/session/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ use crate::service::workspace::{get_global_workspace_service, WorkspaceInfo, Wor
use crate::util::errors::{BitFunError, BitFunResult};
use crate::util::sanitize_plain_model_output;
use crate::util::timing::elapsed_ms_u64;
use bitfun_core_types::SessionExecutionTarget;
pub use bitfun_runtime_ports::SessionViewRestoreTiming;
use bitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort};
use bitfun_services_core::session::{
Expand All @@ -50,7 +51,6 @@ use bitfun_services_core::session::{
set_deep_review_run_manifest, set_review_target_evidence, set_session_relationship,
SessionStorageLayout,
};
use bitfun_core_types::SessionExecutionTarget;
use dashmap::{mapref::entry::Entry, DashMap};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -168,6 +168,20 @@ pub struct SessionExecutionBindingUpdate {
pub execution_target: SessionExecutionTarget,
}

/// Stable failure categories for atomically moving a session execution root.
///
/// Worktree lifecycle maps these categories to its public structured error
/// contract without having to inspect human-readable `BitFunError` messages.
#[derive(Debug, thiserror::Error)]
pub enum SessionExecutionBindingError {
#[error("{0}")]
Busy(String),
#[error("{0}")]
NotFound(String),
#[error(transparent)]
Internal(#[from] BitFunError),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionResourceCleanupPolicy {
BestEffort,
Expand Down Expand Up @@ -3497,34 +3511,51 @@ impl SessionManager {
&self,
session_id: &str,
binding: SessionExecutionBindingUpdate,
) -> BitFunResult<()> {
) -> Result<(), SessionExecutionBindingError> {
// Mirrors update_session_model_id: an evicted session must be restored
// from its recorded storage path before the mutation permit is taken.
// before the mutation permit is taken. View-only historical restores do
// not populate the storage-path index, so use the owning project path as
// the stable fallback locator.
if !self.sessions.contains_key(session_id) && self.config.enable_persistence {
let session_storage_path = self
.session_storage_path_index
.get(session_id)
.map(|entry| entry.value().path.clone());
if let Some(session_storage_path) = session_storage_path {
let _ = self
.restore_session_from_storage_path(&session_storage_path, session_id)
.await;
let restore_result = if let Some(session_storage_path) = session_storage_path {
self.restore_session_from_storage_path(&session_storage_path, session_id)
.await
} else {
self.restore_session(Path::new(&binding.project_workspace_path), session_id)
.await
};
if let Err(restore_error) = restore_result {
return match restore_error {
BitFunError::NotFound(message) => {
Err(SessionExecutionBindingError::NotFound(message))
}
other => Err(SessionExecutionBindingError::Internal(other)),
};
}
}

let _mutation_guard = self.acquire_session_mutation(session_id).await?;

if let Some(mut session) = self.sessions.get_mut(session_id) {
if !session.dialog_turn_ids.is_empty() || !matches!(session.state, SessionState::Idle) {
return Err(SessionExecutionBindingError::Busy(
"Worktree isolation can only be changed before the session's first message"
.to_string(),
));
}
session.config.workspace_path = Some(binding.workspace_path.clone());
session.config.project_workspace_path = Some(binding.project_workspace_path.clone());
session.config.execution_target = Some(binding.execution_target.clone());
session.config.workspace_id = binding.workspace_id.clone();
session.updated_at = SystemTime::now();
session.last_activity_at = SystemTime::now();
} else {
return Err(BitFunError::NotFound(format!(
"Session not found: {}",
session_id
return Err(SessionExecutionBindingError::NotFound(format!(
"Session not found: {session_id}"
)));
}

Expand Down Expand Up @@ -6819,8 +6850,8 @@ impl SessionManager {
#[cfg(test)]
mod tests {
use super::{
should_auto_migrate_session_model, CoreSessionStorePort, SessionManager,
SessionManagerConfig,
should_auto_migrate_session_model, CoreSessionStorePort, SessionExecutionBindingError,
SessionExecutionBindingUpdate, SessionManager, SessionManagerConfig,
};
use crate::agentic::core::{
CompressionState, Message, MessageContent, MessageRole, ProcessingPhase, Session,
Expand All @@ -6841,6 +6872,7 @@ mod tests {
SessionRelationship, SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData,
TurnStatus, UserMessageData,
};
use bitfun_core_types::SessionExecutionTarget;
use bitfun_runtime_ports::SessionStoragePathRequest;
use dashmap::{try_result::TryResult, DashMap};
use serde_json::json;
Expand Down Expand Up @@ -7053,6 +7085,103 @@ mod tests {
)
}

#[tokio::test]
async fn execution_binding_rejects_a_session_after_its_first_turn() {
let manager = in_memory_test_manager();
let workspace = TestWorkspace::new();
let session = manager
.create_session(
"Binding race".to_string(),
"agentic".to_string(),
SessionConfig {
workspace_path: Some(workspace.path().to_string_lossy().to_string()),
..SessionConfig::default()
},
)
.await
.expect("session should be created");
manager
.sessions
.get_mut(&session.session_id)
.expect("session should remain loaded")
.dialog_turn_ids
.push("turn-1".to_string());

let error = manager
.update_session_execution_binding(
&session.session_id,
SessionExecutionBindingUpdate {
workspace_path: "/tmp/worktree".to_string(),
project_workspace_path: workspace.path().to_string_lossy().to_string(),
workspace_id: None,
execution_target: SessionExecutionTarget::local("/tmp/worktree".to_string()),
},
)
.await
.expect_err("a non-empty session must not move");

assert!(matches!(error, SessionExecutionBindingError::Busy(_)));
assert_eq!(
manager
.get_session(&session.session_id)
.and_then(|session| session.config.workspace_path),
Some(workspace.path().to_string_lossy().to_string())
);
}

#[tokio::test]
async fn execution_binding_restores_a_view_only_empty_session_from_its_project() {
let workspace = TestWorkspace::new();
let persistence_manager = Arc::new(
PersistenceManager::new(workspace.path_manager()).expect("persistence manager"),
);
let manager = test_manager(persistence_manager);
let session = manager
.create_session(
"View-only binding".to_string(),
"agentic".to_string(),
SessionConfig {
workspace_path: Some(workspace.path().to_string_lossy().to_string()),
project_workspace_path: Some(workspace.path().to_string_lossy().to_string()),
..SessionConfig::default()
},
)
.await
.expect("session should be created");
assert!(manager
.unload_session_from_memory(&session.session_id)
.await
.expect("session should unload"));
manager
.session_storage_path_index
.remove(&session.session_id);

let target_path = workspace.path().join("managed-worktree");
manager
.update_session_execution_binding(
&session.session_id,
SessionExecutionBindingUpdate {
workspace_path: target_path.to_string_lossy().to_string(),
project_workspace_path: workspace.path().to_string_lossy().to_string(),
workspace_id: Some("workspace-2".to_string()),
execution_target: SessionExecutionTarget::local(
target_path.to_string_lossy().to_string(),
),
},
)
.await
.expect("view-only session should restore and rebind");

let restored = manager
.get_session(&session.session_id)
.expect("session should be loaded after rebinding");
assert_eq!(
restored.config.workspace_path.as_deref(),
Some(target_path.to_string_lossy().as_ref())
);
assert_eq!(restored.config.workspace_id.as_deref(), Some("workspace-2"));
}

#[tokio::test]
async fn unloading_a_session_releases_capacity_without_deleting_persistence() {
let workspace = TestWorkspace::new();
Expand Down
24 changes: 20 additions & 4 deletions src/crates/assembly/core/src/service/worktree/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1174,18 +1174,19 @@ fn resolve_managed_root(
path_manager: &PathManager,
) -> Result<PathBuf, WorktreeError> {
let configured = settings.root_path.trim();
if configured.is_empty() || configured == "~/.bitfun/worktrees" {
let portable_configured = configured.replace('\\', "/");
if portable_configured.is_empty() || portable_configured == "~/.bitfun/worktrees" {
return Ok(path_manager.worktrees_root());
}
if configured == "~" {
if portable_configured == "~" {
return dirs::home_dir().ok_or_else(|| {
error(
WorktreeErrorCode::InvalidPath,
"Unable to resolve the configured home directory",
)
});
}
if let Some(suffix) = configured.strip_prefix("~/") {
if let Some(suffix) = portable_configured.strip_prefix("~/") {
return dirs::home_dir()
.map(|home| home.join(suffix))
.ok_or_else(|| {
Expand All @@ -1199,7 +1200,7 @@ fn resolve_managed_root(
if !path.is_absolute() {
return Err(error(
WorktreeErrorCode::InvalidPath,
"Worktree root must be an absolute path or start with ~/",
"Worktree root must be an absolute path or start with ~/ (or ~\\ on Windows)",
));
}
Ok(path)
Expand Down Expand Up @@ -1486,6 +1487,21 @@ mod tests {
assert!(resolve_managed_root(&settings, &path_manager).is_err());
}

#[test]
fn windows_style_default_root_uses_the_managed_path_contract() {
let user_root = std::env::temp_dir().join("bitfun-worktree-root-test");
let path_manager = PathManager::with_user_root_for_tests(user_root);
let settings = WorktreeSettings {
root_path: r"~\.bitfun\worktrees".to_string(),
..WorktreeSettings::default()
};

assert_eq!(
resolve_managed_root(&settings, &path_manager).unwrap(),
path_manager.worktrees_root()
);
}

#[test]
fn request_ids_map_to_stable_session_ids() {
let first = WorktreeService::session_id_for_request("request-123").unwrap();
Expand Down
Loading