From acaa8e89082320c833c2ba2bcfffa361735a9d5a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 27 Mar 2026 19:30:37 +0800 Subject: [PATCH] fix(assistant): rebind stale thread workspaces --- lib/app/app_controller_desktop.dart | 77 +++++++++++++++++-- ...ntroller_assistant_workspace_ref_test.dart | 58 ++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 11686b66..bf828468 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -1344,6 +1344,70 @@ class AppController extends ChangeNotifier { normalizedRef == settings.workspacePath.trim(); } + bool _usesDefaultThreadWorkspaceRefFromAnotherRoot( + String sessionKey, { + AssistantExecutionTarget? executionTarget, + String? workspaceRef, + WorkspaceRefKind? workspaceRefKind, + }) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final resolvedTarget = + executionTarget ?? + assistantExecutionTargetForSession(normalizedSessionKey); + if (resolvedTarget == AssistantExecutionTarget.remote) { + return false; + } + final normalizedRef = workspaceRef?.trim() ?? ''; + if (normalizedRef.isEmpty || workspaceRefKind != WorkspaceRefKind.localPath) { + return false; + } + final expectedDefault = _defaultWorkspaceRefForSession( + normalizedSessionKey, + ).trim(); + if (expectedDefault.isEmpty) { + return false; + } + final normalizedPath = _trimTrailingPathSeparator( + normalizedRef.replaceAll('\\', '/'), + ); + final normalizedExpected = _trimTrailingPathSeparator( + expectedDefault.replaceAll('\\', '/'), + ); + if (normalizedPath == normalizedExpected) { + return false; + } + if (normalizedSessionKey == 'main') { + return normalizedPath == SettingsSnapshot.defaults().workspacePath; + } + final expectedSuffix = + '/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}'; + return normalizedPath.endsWith(expectedSuffix); + } + + bool _shouldMigrateWorkspaceRef( + String sessionKey, { + AssistantExecutionTarget? executionTarget, + String? workspaceRef, + WorkspaceRefKind? workspaceRefKind, + }) { + final normalizedRef = workspaceRef?.trim() ?? ''; + if (normalizedRef.isEmpty) { + return true; + } + return _usesLegacySharedWorkspaceRef( + sessionKey, + executionTarget: executionTarget, + workspaceRef: normalizedRef, + workspaceRefKind: workspaceRefKind, + ) || + _usesDefaultThreadWorkspaceRefFromAnotherRoot( + sessionKey, + executionTarget: executionTarget, + workspaceRef: normalizedRef, + workspaceRefKind: workspaceRefKind, + ); + } + WorkspaceRefKind _defaultWorkspaceRefKindForTarget( AssistantExecutionTarget target, ) { @@ -1373,7 +1437,7 @@ class AppController extends ChangeNotifier { if (existing != null && existingWorkspaceRef.isNotEmpty && existing.workspaceRefKind == nextWorkspaceRefKind && - !_usesLegacySharedWorkspaceRef( + !_shouldMigrateWorkspaceRef( normalizedSessionKey, executionTarget: resolvedTarget, workspaceRef: existingWorkspaceRef, @@ -2704,8 +2768,7 @@ class AppController extends ChangeNotifier { _resolvedUserHomeDirectory = await _skillDirectoryAccessService .resolveUserHomeDirectory(); await _settingsController.initialize(); - _restoreAssistantThreads(await _store.loadAssistantThreadRecords()); - await _restoreSharedSingleAgentLocalSkillsCache(); + final storedAssistantThreads = await _store.loadAssistantThreadRecords(); if (_disposed) { return; } @@ -2737,6 +2800,11 @@ class AppController extends ChangeNotifier { return; } } + _restoreAssistantThreads(storedAssistantThreads); + await _restoreSharedSingleAgentLocalSkillsCache(); + if (_disposed) { + return; + } _lastObservedSettingsSnapshot = settings; _modelsController.restoreFromSettings(settings.aiGateway); _multiAgentOrchestrator.updateConfig(settings.multiAgent); @@ -3559,8 +3627,7 @@ class AppController extends ChangeNotifier { } final titleFromSettings = assistantCustomTaskTitle(sessionKey); final shouldMigrateWorkspaceRef = - record.workspaceRef.trim().isEmpty || - _usesLegacySharedWorkspaceRef( + _shouldMigrateWorkspaceRef( sessionKey, executionTarget: record.executionTarget ?? settings.assistantExecutionTarget, diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index 761ef574..028434f9 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -231,4 +231,62 @@ void main() { ); }, ); + + test( + 'AppController rebinds default thread workspaces after bootstrap updates the workspace root', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-bootstrap-migrate-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot(SettingsSnapshot.defaults()); + await store.saveAssistantThreadRecords([ + AssistantThreadRecord( + sessionKey: 'draft:artifact-thread', + messages: const [], + updatedAtMs: 1, + title: 'Artifact Thread', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: '/opt/data/.xworkmate/threads/draft-artifact-thread', + workspaceRefKind: WorkspaceRefKind.localPath, + ), + ]); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (controller.initializing) { + if (DateTime.now().isAfter(deadline)) { + fail('controller did not initialize in time'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + + expect(controller.settings.workspacePath, isNot('/opt/data')); + final migratedWorkspace = controller.assistantWorkspaceRefForSession( + 'draft:artifact-thread', + ); + expect( + migratedWorkspace, + '${controller.settings.workspacePath}/.xworkmate/threads/draft-artifact-thread', + ); + expect(Directory(migratedWorkspace).existsSync(), isTrue); + }, + ); }