From ef18a068be6daa8e35f8be2f204e18e1ec7c50de Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 28 Mar 2026 14:52:14 +0800 Subject: [PATCH] refactor thread workspace resolution and align architecture docs --- ...sistant-thread-information-architecture.md | 36 ++++++++- .../xworkmate-internal-state-architecture.md | 21 +++++ .../app_controller_desktop_single_agent.dart | 11 --- ...ntroller_desktop_thread_sessions.part.dart | 78 ++++++++----------- ...ontroller_desktop_thread_storage.part.dart | 2 - .../assistant/assistant_page_main.part.dart | 10 +-- lib/runtime/runtime_bootstrap.dart | 4 +- .../runtime_models_runtime_payloads.part.dart | 3 +- ...runtime_models_settings_snapshot.part.dart | 4 +- ..._gateway_chat_suite_single_agent.part.dart | 10 +-- ...ntroller_assistant_workspace_ref_test.dart | 67 +++++++++++++++- 11 files changed, 166 insertions(+), 80 deletions(-) diff --git a/docs/architecture/assistant-thread-information-architecture.md b/docs/architecture/assistant-thread-information-architecture.md index 01ed1d47..19cd75cf 100644 --- a/docs/architecture/assistant-thread-information-architecture.md +++ b/docs/architecture/assistant-thread-information-architecture.md @@ -144,6 +144,38 @@ flowchart LR | 发送前附件草稿 | 否 | 当前只有页面级 `_attachments` | | 导出 | 否 | 未实现 | +## 线程工作目录与 WorkspaceRefKind(当前实现) + +### 统一线程工作目录规则 + +当前 Desktop 实现中,所有线程(含 `main`)统一使用: + +`workspacePath/.xworkmate/threads/` + +其中: + +- `` 会经过目录名安全化(非法字符替换) +- 目录不存在时会自动创建 +- 线程切换与恢复时,如发现旧记录目录缺失或仍指向共享根目录,会自动迁移到该统一目录 + +### `workspaceRefKind` 的语义(与路径解耦) + +`workspaceRefKind` 用来表达运行通道语义,而不是决定目录拼接规则: + +- 本地 Agent(`singleAgent`)=> `localPath` +- OpenClaw Gateway(`local` / `remote`)=> `remotePath` + +注意:即使 `workspaceRefKind = remotePath`,线程目录仍然按统一规则落在 +`workspacePath/.xworkmate/threads/`。 + +### 已清理的旧行为 + +以下旧行为不再作为当前实现: + +- `main` 线程直接使用 `workspacePath` 根目录 +- 通过 `remoteProjectRoot` 单独决定线程目录 +- Single Agent runner 返回 `resolvedWorkingDirectory` 后覆盖线程目录 + ## 当前交互规则 ### 新建线程 @@ -204,5 +236,5 @@ flowchart LR ## 相关文档 -- [模式切换与线程连续追问](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/cases/thread_mode_switch_followup.md) -- [XWorkmate 集成架构](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/architecture/xworkmate-integrations.md) +- [模式切换与线程连续追问](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/cases/thread_mode_switch_followup.md) +- [XWorkmate 集成架构](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/xworkmate-integrations.md) diff --git a/docs/architecture/xworkmate-internal-state-architecture.md b/docs/architecture/xworkmate-internal-state-architecture.md index 4905a8dc..f8414397 100644 --- a/docs/architecture/xworkmate-internal-state-architecture.md +++ b/docs/architecture/xworkmate-internal-state-architecture.md @@ -265,6 +265,27 @@ AssistantThreadRecord fields that matter most: - assistantModelId - gatewayEntryState - messages +- workspaceRef +- workspaceRefKind + +Thread workspace resolution (Desktop current baseline): + +- All sessions (including `main`) resolve to: + `settings.workspacePath/.xworkmate/threads/` +- Session directory is created on demand when missing +- Legacy records that point to shared root or missing directories are migrated + back to the per-session isolated path +- `workspaceRefKind` is runtime-channel semantics, not path-layout semantics: + - `singleAgent` => `localPath` + - gateway-backed targets (`local` / `remote`) => `remotePath` + - both still use the same isolated thread directory path pattern above + +Important cleanup note: + +- `remoteProjectRoot` is no longer used as a separate thread workspace path + selector in Desktop thread workspace resolution. +- Runtime-reported `resolvedWorkingDirectory` from single-agent execution + should not overwrite the canonical per-thread workspaceRef path. Responsibilities: - Hold per-thread overrides diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index e0ba1cb4..d70286b9 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -112,17 +112,6 @@ extension AppControllerDesktopSingleAgent on AppController { if (resolvedRuntimeModel.isNotEmpty) { _singleAgentRuntimeModelBySession[sessionKey] = resolvedRuntimeModel; } - final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim(); - if (resolvedWorkingDirectory.isNotEmpty) { - _upsertAssistantThreadRecord( - sessionKey, - workspaceRef: resolvedWorkingDirectory, - workspaceRefKind: - result.resolvedWorkspaceRefKind ?? - assistantWorkspaceRefKindForSession(sessionKey), - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - } _clearAiGatewayStreamingText(sessionKey); if (result.aborted) { final partial = result.output.trim(); diff --git a/lib/app/app_controller_desktop_thread_sessions.part.dart b/lib/app/app_controller_desktop_thread_sessions.part.dart index 0fab59d9..1c2faba4 100644 --- a/lib/app/app_controller_desktop_thread_sessions.part.dart +++ b/lib/app/app_controller_desktop_thread_sessions.part.dart @@ -717,19 +717,14 @@ extension AppControllerDesktopThreadSessions on AppController { String _defaultWorkspaceRefForSession(String sessionKey) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - final target = assistantExecutionTargetForSession(normalizedSessionKey); - return switch (target) { - AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(), - AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent => - _defaultLocalWorkspaceRefForSession(normalizedSessionKey), - }; + return _defaultLocalWorkspaceRefForSession(normalizedSessionKey); } String _defaultLocalWorkspaceRefForSession(String sessionKey) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); final baseWorkspace = settings.workspacePath.trim(); - if (baseWorkspace.isEmpty || normalizedSessionKey == 'main') { - return baseWorkspace; + if (baseWorkspace.isEmpty) { + return ''; } final threadWorkspace = '${_trimTrailingPathSeparator(baseWorkspace)}/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}'; @@ -766,21 +761,9 @@ extension AppControllerDesktopThreadSessions on AppController { } bool _usesLegacySharedWorkspaceRef( - String sessionKey, { - AssistantExecutionTarget? executionTarget, String? workspaceRef, WorkspaceRefKind? workspaceRefKind, - }) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - if (normalizedSessionKey == 'main') { - return false; - } - final resolvedTarget = - executionTarget ?? - assistantExecutionTargetForSession(normalizedSessionKey); - if (resolvedTarget == AssistantExecutionTarget.remote) { - return false; - } + ) { final normalizedRef = workspaceRef?.trim() ?? ''; if (normalizedRef.isEmpty) { return false; @@ -791,17 +774,10 @@ extension AppControllerDesktopThreadSessions on AppController { 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) { @@ -822,9 +798,6 @@ extension AppControllerDesktopThreadSessions on AppController { if (normalizedPath == normalizedExpected) { return false; } - if (normalizedSessionKey == 'main') { - return normalizedPath == SettingsSnapshot.defaults().workspacePath; - } final expectedSuffix = '/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}'; return normalizedPath.endsWith(expectedSuffix); @@ -832,7 +805,6 @@ extension AppControllerDesktopThreadSessions on AppController { bool _shouldMigrateWorkspaceRef( String sessionKey, { - AssistantExecutionTarget? executionTarget, String? workspaceRef, WorkspaceRefKind? workspaceRefKind, }) { @@ -840,27 +812,40 @@ extension AppControllerDesktopThreadSessions on AppController { if (normalizedRef.isEmpty) { return true; } - return _usesLegacySharedWorkspaceRef( - sessionKey, - executionTarget: executionTarget, - workspaceRef: normalizedRef, - workspaceRefKind: workspaceRefKind, - ) || + if (_usesMissingWorkspaceRef(sessionKey, workspaceRefKind, normalizedRef)) { + return true; + } + return _usesLegacySharedWorkspaceRef(normalizedRef, workspaceRefKind) || _usesDefaultThreadWorkspaceRefFromAnotherRoot( sessionKey, - executionTarget: executionTarget, workspaceRef: normalizedRef, workspaceRefKind: workspaceRefKind, ); } + bool _usesMissingWorkspaceRef( + String sessionKey, + WorkspaceRefKind? workspaceRefKind, + String workspaceRef, + ) { + if (workspaceRefKind != WorkspaceRefKind.localPath) { + return false; + } + final normalizedPath = workspaceRef.trim(); + if (normalizedPath.isEmpty) { + return true; + } + return FileSystemEntity.typeSync(normalizedPath) == + FileSystemEntityType.notFound; + } + WorkspaceRefKind _defaultWorkspaceRefKindForTarget( AssistantExecutionTarget target, ) { return switch (target) { - AssistantExecutionTarget.remote => WorkspaceRefKind.remotePath, - AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent => WorkspaceRefKind.localPath, + AssistantExecutionTarget.local || + AssistantExecutionTarget.remote => WorkspaceRefKind.remotePath, }; } @@ -869,14 +854,12 @@ extension AppControllerDesktopThreadSessions on AppController { AssistantExecutionTarget? executionTarget, }) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - final resolvedTarget = - executionTarget ?? - assistantExecutionTargetForSession(normalizedSessionKey); final nextWorkspaceRef = _defaultWorkspaceRefForSession( normalizedSessionKey, ); final nextWorkspaceRefKind = _defaultWorkspaceRefKindForTarget( - resolvedTarget, + executionTarget ?? + assistantExecutionTargetForSession(normalizedSessionKey), ); final existing = _assistantThreadRecords[normalizedSessionKey]; final existingWorkspaceRef = existing?.workspaceRef.trim() ?? ''; @@ -885,7 +868,6 @@ extension AppControllerDesktopThreadSessions on AppController { existing.workspaceRefKind == nextWorkspaceRefKind && !_shouldMigrateWorkspaceRef( normalizedSessionKey, - executionTarget: resolvedTarget, workspaceRef: existingWorkspaceRef, workspaceRefKind: existing.workspaceRefKind, )) { @@ -898,7 +880,9 @@ extension AppControllerDesktopThreadSessions on AppController { } _upsertAssistantThreadRecord( normalizedSessionKey, - executionTarget: resolvedTarget, + executionTarget: + executionTarget ?? + assistantExecutionTargetForSession(normalizedSessionKey), workspaceRef: nextWorkspaceRef, workspaceRefKind: nextWorkspaceRefKind, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), diff --git a/lib/app/app_controller_desktop_thread_storage.part.dart b/lib/app/app_controller_desktop_thread_storage.part.dart index 0eef3fb4..e694f652 100644 --- a/lib/app/app_controller_desktop_thread_storage.part.dart +++ b/lib/app/app_controller_desktop_thread_storage.part.dart @@ -587,8 +587,6 @@ extension AppControllerDesktopThreadStorage on AppController { final titleFromSettings = assistantCustomTaskTitle(sessionKey); final shouldMigrateWorkspaceRef = _shouldMigrateWorkspaceRef( sessionKey, - executionTarget: - record.executionTarget ?? settings.assistantExecutionTarget, workspaceRef: record.workspaceRef, workspaceRefKind: record.workspaceRefKind, ); diff --git a/lib/features/assistant/assistant_page_main.part.dart b/lib/features/assistant/assistant_page_main.part.dart index fdaaf075..66bf1408 100644 --- a/lib/features/assistant/assistant_page_main.part.dart +++ b/lib/features/assistant/assistant_page_main.part.dart @@ -773,8 +773,9 @@ class _AssistantPageState extends State { executionTarget: executionTarget, singleAgentProvider: controller.currentSingleAgentProvider, permissionLevel: settings.assistantPermissionLevel, - workspacePath: settings.workspacePath, - remoteProjectRoot: settings.remoteProjectRoot, + workspacePath: controller.assistantWorkspaceRefForSession( + controller.currentSessionKey, + ), ); setState(() { @@ -985,7 +986,6 @@ class _AssistantPageState extends State { required SingleAgentProvider singleAgentProvider, required AssistantPermissionLevel permissionLevel, required String workspacePath, - required String remoteProjectRoot, }) { final attachmentBlock = attachmentNames.isEmpty ? '' @@ -993,9 +993,7 @@ class _AssistantPageState extends State { final skillBlock = selectedSkillLabels.isEmpty ? '' : 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n'; - final targetRoot = executionTarget == AssistantExecutionTarget.local - ? workspacePath.trim() - : remoteProjectRoot.trim(); + final targetRoot = workspacePath.trim(); final executionContext = 'Execution context:\n' '- target: ${executionTarget.promptValue}\n' diff --git a/lib/runtime/runtime_bootstrap.dart b/lib/runtime/runtime_bootstrap.dart index 4e94ac06..732aaf2d 100644 --- a/lib/runtime/runtime_bootstrap.dart +++ b/lib/runtime/runtime_bootstrap.dart @@ -96,7 +96,9 @@ class RuntimeBootstrapConfig { value.trim().isEmpty || value.trim() == '/opt/data'; static bool _isDefaultRemoteRoot(String value) => - value.trim().isEmpty || value.trim() == '/opt/data/workspace'; + value.trim().isEmpty || + value.trim() == '/opt/data/workspace' || + value.trim() == '/opt/data'; static bool _isDefaultCliPath(String value) => value.trim().isEmpty || value.trim() == 'openclaw'; diff --git a/lib/runtime/runtime_models_runtime_payloads.part.dart b/lib/runtime/runtime_models_runtime_payloads.part.dart index 19e58e4c..da4de106 100644 --- a/lib/runtime/runtime_models_runtime_payloads.part.dart +++ b/lib/runtime/runtime_models_runtime_payloads.part.dart @@ -603,7 +603,8 @@ class AssistantThreadRecord { if (workspaceRef.startsWith('object://')) { return WorkspaceRefKind.objectStore; } - if (executionTarget == AssistantExecutionTarget.remote) { + if (executionTarget != null && + executionTarget != AssistantExecutionTarget.singleAgent) { return WorkspaceRefKind.remotePath; } return WorkspaceRefKind.localPath; diff --git a/lib/runtime/runtime_models_settings_snapshot.part.dart b/lib/runtime/runtime_models_settings_snapshot.part.dart index 95eabeaa..c76003c7 100644 --- a/lib/runtime/runtime_models_settings_snapshot.part.dart +++ b/lib/runtime/runtime_models_settings_snapshot.part.dart @@ -79,8 +79,8 @@ class SettingsSnapshot { appActive: true, launchAtLogin: false, showDockIcon: true, - workspacePath: '/opt/data', - remoteProjectRoot: '/opt/data/workspace', + workspacePath: '', + remoteProjectRoot: '', cliPath: 'openclaw', codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, codexCliPath: '', diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart index 713bade0..6822f528 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.part.dart @@ -405,7 +405,7 @@ void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() { ); test( - 'AppController adopts and reuses resolved remote single-agent thread workspaces', + 'AppController keeps isolated thread workspace even when runner reports another directory', () async { final tempDirectory = await _createTempDirectory( 'xworkmate-single-agent-remote-thread-cwd-', @@ -434,7 +434,7 @@ void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() { shouldFallbackToAiChat: false, resolvedWorkingDirectory: '/opt/data/.xworkmate/threads/draft-remote-thread', - resolvedWorkspaceRefKind: WorkspaceRefKind.remotePath, + resolvedWorkspaceRefKind: WorkspaceRefKind.localPath, ), ); final controller = await _createAppController( @@ -463,17 +463,17 @@ void _registerAppControllerAiGatewayChatSuiteSingleAgentTests() { ); expect( controller.assistantWorkspaceRefForSession('draft:remote-thread'), - '/opt/data/.xworkmate/threads/draft-remote-thread', + '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread', ); expect( controller.assistantWorkspaceRefKindForSession('draft:remote-thread'), - WorkspaceRefKind.remotePath, + WorkspaceRefKind.localPath, ); await controller.sendChatMessage('第二次运行', thinking: 'low'); expect( runner.requests.last.workingDirectory, - '/opt/data/.xworkmate/threads/draft-remote-thread', + '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread', ); }, ); diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index 028434f9..7d5622c2 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -33,13 +33,13 @@ void main() { controller.assistantWorkspaceRefForSession( controller.currentSessionKey, ), - controller.settings.workspacePath, + '${controller.settings.workspacePath}/.xworkmate/threads/main', ); expect( controller.assistantWorkspaceRefKindForSession( controller.currentSessionKey, ), - WorkspaceRefKind.localPath, + WorkspaceRefKind.remotePath, ); await controller.setAssistantExecutionTarget( @@ -49,7 +49,7 @@ void main() { controller.assistantWorkspaceRefForSession( controller.currentSessionKey, ), - controller.settings.remoteProjectRoot, + '${controller.settings.workspacePath}/.xworkmate/threads/main', ); expect( controller.assistantWorkspaceRefKindForSession( @@ -289,4 +289,65 @@ void main() { expect(Directory(migratedWorkspace).existsSync(), isTrue); }, ); + + test( + 'AppController migrates missing draft workspace refs to isolated thread directories', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-missing-', + ); + final workspaceRoot = Directory('${tempDirectory.path}/workspace'); + await workspaceRoot.create(recursive: true); + 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().copyWith(workspacePath: workspaceRoot.path), + ); + await store.saveAssistantThreadRecords([ + AssistantThreadRecord( + sessionKey: 'draft:missing-ref-thread', + messages: const [], + updatedAtMs: 1, + title: 'Missing Ref Thread', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: '${workspaceRoot.path}/.xworkmate/threads/missing-dir', + 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)); + } + + final migratedWorkspace = controller.assistantWorkspaceRefForSession( + 'draft:missing-ref-thread', + ); + expect( + migratedWorkspace, + '${workspaceRoot.path}/.xworkmate/threads/draft-missing-ref-thread', + ); + expect(Directory(migratedWorkspace).existsSync(), isTrue); + }, + ); }