diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 55aac1fe20..aaaabb223b 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -87,11 +87,13 @@ pub struct AppState { /// Cancellation flags for active file transfers (download/upload), keyed by transfer_id. pub active_transfers: Arc>>>, pub announcement_scheduler: Arc, + pub is_primary_instance: bool, } impl AppState { pub async fn new_async( token_usage_service: Arc, + is_primary_instance: bool, ) -> BitFunResult { let start_time = std::time::Instant::now(); @@ -311,6 +313,7 @@ impl AppState { active_searches: Arc::new(Mutex::new(HashMap::new())), active_transfers: Arc::new(Mutex::new(HashMap::new())), announcement_scheduler, + is_primary_instance, }; if let Some(workspace_info) = initial_workspace { diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 43cfad3805..4afb017a8c 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -445,8 +445,9 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { pub async fn minimize_to_tray( app: tauri::AppHandle, startup_trace: State<'_, DesktopStartupTrace>, + state: State<'_, AppState>, ) -> Result<(), String> { - if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { + if let Err(error) = crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance) { log::warn!("Failed to initialize tray before minimizing: {}", error); } if let Some(window) = app.get_webview_window("main") { @@ -461,8 +462,9 @@ pub async fn minimize_to_tray( pub async fn initialize_tray_after_startup( app: tauri::AppHandle, startup_trace: State<'_, DesktopStartupTrace>, + state: State<'_, AppState>, ) -> Result<(), String> { - crate::tray::setup_tray(&app, &startup_trace).map_err(|e| e.to_string()) + crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance).map_err(|e| e.to_string()) } /// Minimal startup-window controls used by the static pre-React splash. @@ -508,7 +510,7 @@ pub async fn startup_window_control( crate::perform_process_exit_cleanup(); app.exit(0); } else { - if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { + if let Err(error) = crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance) { log::warn!("Failed to initialize tray before startup close: {}", error); } window.hide().map_err(|error| { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 36cba2610a..4c1855ae8a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -6,6 +6,7 @@ pub mod computer_use; pub mod crash_diagnostics; pub mod logging; pub mod macos_menubar; +pub mod single_instance; pub mod startup_trace; pub mod theme; pub mod tray; @@ -209,6 +210,7 @@ fn get_startup_native_trace( /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { + let is_primary = single_instance::is_primary_instance(); let startup_started = Instant::now(); let startup_trace_id = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -320,7 +322,7 @@ pub async fn run() { ); let step_started = Instant::now(); - let app_state = match AppState::new_async(token_usage_service).await { + let app_state = match AppState::new_async(token_usage_service, is_primary).await { Ok(state) => state, Err(e) => { log::error!("Failed to initialize AppState: {}", e); diff --git a/src/apps/desktop/src/single_instance.rs b/src/apps/desktop/src/single_instance.rs new file mode 100644 index 0000000000..811c0fd985 --- /dev/null +++ b/src/apps/desktop/src/single_instance.rs @@ -0,0 +1,90 @@ +//! Per-process single-instance detection via OS primitives. +//! +//! On Windows a named kernel mutex ensures at most one process holds the +//! "primary" role. On other platforms the function always returns `true` +//! because the system tray icon duplication issue is Windows-specific. + +#[cfg(target_os = "windows")] +mod imp { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::sync::OnceLock; + + extern "system" { + fn CreateMutexW( + lp_mutex_attributes: *const std::ffi::c_void, + b_initial_owner: i32, + lp_name: *const u16, + ) -> isize; + fn GetLastError() -> u32; + } + + const ERROR_ALREADY_EXISTS: u32 = 183; + + /// The handle is kept alive for the lifetime of the primary process so the + /// kernel mutex is not destroyed prematurely. Non-primary instances + /// intentionally leak their handle — it is harmless and avoids a separate + /// `CloseHandle` extern declaration (which would clash with the existing + /// `windows`‑crate declaration in the same build graph). + static PRIMARY_MUTEX: OnceLock = OnceLock::new(); + + pub(crate) fn is_primary_instance_impl() -> bool { + #[cfg(not(test))] + let name: Vec = OsStr::new("Global\\BitFun_Desktop_Instance_Mutex") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + #[cfg(test)] + let name: Vec = OsStr::new("Global\\BitFun_Desktop_Instance_Mutex_Test") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + if handle == 0 { + // If we cannot create the mutex, conservatively claim we're the + // primary instance so the tray icon is not silently lost. + return true; + } + + let is_primary = unsafe { GetLastError() } != ERROR_ALREADY_EXISTS; + + if is_primary { + PRIMARY_MUTEX.set(handle).ok(); + } + // Non-primary: intentionally leak the duplicate handle — the kernel + // object is still owned by the primary process and the leaked handle + // is reclaimed by the OS when this process exits. + + is_primary + } +} + +#[cfg(not(target_os = "windows"))] +mod imp { + pub(crate) fn is_primary_instance_impl() -> bool { + true + } +} + +pub(crate) fn is_primary_instance() -> bool { + imp::is_primary_instance_impl() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_call_primary_second_not() { + // The first call in a process should always report primary, and a + // second call must not because the mutex is already held. Verifying + // both assertions in the same test avoids the ordering dependency + // that would exist between two separate tests sharing the mutex. + assert!(is_primary_instance(), "first call must report primary"); + assert!( + !is_primary_instance(), + "second call must not report primary" + ); + } +} diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 1ed4b2be9e..ee0de98c2f 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -31,6 +31,10 @@ static TRAY_ICON: OnceLock = OnceLock::new(); static TRAY_SETUP_LOCK: Mutex<()> = Mutex::new(()); const TRAY_TRACE_CATEGORY: &str = "native_background"; +pub(crate) fn should_create_tray(is_primary: bool) -> bool { + is_primary && TRAY_ICON.get().is_none() +} + struct TrayStrings { show_app: &'static str, quit_app: &'static str, @@ -171,7 +175,12 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> { pub fn setup_tray( app: &tauri::AppHandle, startup_trace: &DesktopStartupTrace, + is_primary: bool, ) -> Result<(), Box> { + if !should_create_tray(is_primary) { + return Ok(()); + } + if TRAY_ICON.get().is_some() { return Ok(()); } @@ -299,3 +308,20 @@ fn toggle_main_window(app: &tauri::AppHandle) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_create_tray_for_primary_without_existing_icon() { + // Before any tray is created, a primary instance should create one. + assert!(should_create_tray(true)); + } + + #[test] + fn should_not_create_tray_for_non_primary() { + // A non-primary instance should never create a tray icon. + assert!(!should_create_tray(false)); + } +} diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 666692c31d..2f94a1ede6 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -82,6 +82,7 @@ export class FlowChatManager { runtimeStatusTimers: new Map(), userCancelledSessionIds: new Set(), handledTerminalTurnEvents: new Set(), + handledPlanDisplayTurns: new Set(), currentWorkspacePath: null }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts index b4f658d20f..a33f0422f5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts @@ -5,6 +5,9 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import type { FlowToolItem } from '../../types/flow-chat'; import type { AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('AcpPermissionToolCardModule'); const pendingAcpPermissionRequests = new Map(); @@ -18,7 +21,7 @@ function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null { function findToolContextById( store: FlowChatStore, toolId: string -): { sessionId: string; turnId: string; itemId: string } | null { +): { sessionId: string; turnId: string; itemId: string; item: FlowToolItem } | null { const state = store.getState(); for (const [sessionId, session] of state.sessions) { for (const turn of session.dialogTurns) { @@ -29,7 +32,7 @@ function findToolContextById( )) as FlowToolItem | undefined; if (item) { - return { sessionId, turnId: turn.id, itemId: item.id }; + return { sessionId, turnId: turn.id, itemId: item.id, item }; } } } @@ -47,6 +50,15 @@ function applyAcpPermissionRequest( return false; } + // Idempotency: skip if the same permission was already applied to this tool + if (toolContext.item.acpPermission?.permissionId === event.permissionId) { + log.debug('Skipping duplicate ACP permission request', { + toolId, + permissionId: event.permissionId, + }); + return true; + } + store.updateModelRoundItem(toolContext.sessionId, toolContext.turnId, toolContext.itemId, { requiresConfirmation: true, userConfirmed: false, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index b0e98dce35..1e341de8e5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -518,6 +518,7 @@ function createFlowChatContext(): FlowChatContext { runtimeStatusTimers: new Map(), userCancelledSessionIds: new Set(), handledTerminalTurnEvents: new Set(), + handledPlanDisplayTurns: new Set(), currentWorkspacePath: null, }; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 1430d321fc..030c62ddb3 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -1160,18 +1160,20 @@ function finalizePendingTurnCompletion( finalizeTurnCompletionState(context, sessionId, turnId); } -function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: string): void { +function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: string): boolean { const pending = context.pendingTurnCompletions.get(sessionId); if (!pending) { - return; + return false; } if (pending.timer) { clearTimeout(pending.timer); } + context.pendingTurnCompletions.delete(sessionId); flushPendingBatchedEvents(context); finalizeTurnCompletionState(context, sessionId, pending.turnId); + return true; } function findFinishingTurnForBackendIdle( @@ -1367,8 +1369,8 @@ export function handleSessionStateChanged(context: FlowChatContext, event: any): machineContext.backendSyncedAt = Date.now(); if (isExpectedFinishingDrift) { - finalizePendingTurnCompletionNow(context, sessionId); - if (stateMachineManager.getCurrentState(sessionId) === SessionExecutionState.FINISHING) { + const didFinalize = finalizePendingTurnCompletionNow(context, sessionId); + if (!didFinalize && stateMachineManager.getCurrentState(sessionId) === SessionExecutionState.FINISHING) { const finishingTurnId = findFinishingTurnForBackendIdle( context, sessionId, @@ -2679,6 +2681,13 @@ function appendPlanDisplayItemsIfNeeded( turnId: string, dialogTurn: DialogTurn ): void { + const planDisplayKey = `${sessionId}:${turnId}`; + if (context.handledPlanDisplayTurns.has(planDisplayKey)) { + log.debug('Skipping duplicate plan display injection', { sessionId, turnId }); + return; + } + context.handledPlanDisplayTurns.add(planDisplayKey); + const modifiedPlanFiles = detectModifiedPlanFiles(dialogTurn); if (modifiedPlanFiles.length === 0) return; @@ -2687,7 +2696,7 @@ function appendPlanDisplayItemsIfNeeded( for (const planFilePath of modifiedPlanFiles) { const planToolItem: FlowToolItem = { - id: `plan-display-${Date.now()}-${Math.random().toString(36).slice(2)}`, + id: `plan-display-${planFilePath}`, type: 'tool', toolName: 'CreatePlan', toolCall: { input: {}, id: '' }, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 1ee02510d7..cc7adfce3c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -81,6 +81,7 @@ describe('MessageModule cancellation', () => { pendingTurnCompletions: new Map(), runtimeStatusTimers: new Map(), handledTerminalTurnEvents: new Set(), + handledPlanDisplayTurns: new Set(), contentBuffers, activeTextItems, }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index f5458afa7e..915c120a0e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -595,6 +595,17 @@ function handleConfirmationNeeded( turnId: string, toolEvent: ConfirmationNeededToolEvent ): void { + const existingItem = store.findToolItem(sessionId, turnId, toolEvent.tool_id) as FlowToolItem | null; + if (existingItem?.status === 'pending_confirmation') { + log.debug('Skipping duplicate tool confirmation needed event', { + sessionId, + turnId, + toolId: toolEvent.tool_id, + toolName: toolEvent.tool_name, + }); + return; + } + store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, { requiresConfirmation: true, status: 'pending_confirmation', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts index 2ee4734cdf..ed97fd42e1 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts @@ -51,6 +51,13 @@ export interface FlowChatContext { * this set is used to make handlers idempotent. Key format: `sessionId:turnId`. */ handledTerminalTurnEvents: Set; + /** + * Turn IDs whose plan-display items have already been appended. + * Guards against duplicate plan display injection when finalizeTurnCompletionState + * is reached via multiple code paths for the same turn. + * Key format: `sessionId:turnId`. + */ + handledPlanDisplayTurns: Set; currentWorkspacePath: string | null; } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index eee87d50ea..9eae7bc322 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2541,6 +2541,32 @@ export class FlowChatStore { return turn; } + // Content-based dedup: skip duplicate plan-display items that reference the same plan file. + // This guards against any code path that might inject a plan-display item more than once. + if ( + item.type === 'tool' && + (item as FlowToolItem).toolName === 'CreatePlan' && + (item as FlowToolItem).toolResult?.result?.plan_file_path + ) { + const incomingPlanPath = (item as FlowToolItem).toolResult!.result!.plan_file_path; + const duplicatePlanItem = targetModelRound.items.find(existingItem => { + if (existingItem.type !== 'tool') return false; + const et = existingItem as FlowToolItem; + return ( + et.toolName === 'CreatePlan' && + et.toolResult?.result?.plan_file_path === incomingPlanPath + ); + }); + if (duplicatePlanItem) { + log.debug('Skipping duplicate plan-display item (same planFilePath)', { + sessionId, + dialogTurnId, + planFilePath: incomingPlanPath, + }); + return turn; + } + } + const updatedModelRounds = [...turn.modelRounds]; const activeAttempts = targetModelRound.attempts ?? deriveRoundAttemptsFromItems(targetModelRound.items); const incomingAttemptId = typeof item.attemptId === 'string' && item.attemptId.length > 0