refactor thread workspace resolution and align architecture docs

This commit is contained in:
Haitao Pan 2026-03-28 14:52:14 +08:00
parent de11eb55c4
commit ef18a068be
11 changed files with 166 additions and 80 deletions

View File

@ -144,6 +144,38 @@ flowchart LR
| 发送前附件草稿 | 否 | 当前只有页面级 `_attachments` |
| 导出 | 否 | 未实现 |
## 线程工作目录与 WorkspaceRefKind当前实现
### 统一线程工作目录规则
当前 Desktop 实现中,所有线程(含 `main`)统一使用:
`workspacePath/.xworkmate/threads/<SessionKey>`
其中:
- `<SessionKey>` 会经过目录名安全化(非法字符替换)
- 目录不存在时会自动创建
- 线程切换与恢复时,如发现旧记录目录缺失或仍指向共享根目录,会自动迁移到该统一目录
### `workspaceRefKind` 的语义(与路径解耦)
`workspaceRefKind` 用来表达运行通道语义,而不是决定目录拼接规则:
- 本地 Agent`singleAgent`=> `localPath`
- OpenClaw Gateway`local` / `remote`=> `remotePath`
注意:即使 `workspaceRefKind = remotePath`,线程目录仍然按统一规则落在
`workspacePath/.xworkmate/threads/<SessionKey>`
### 已清理的旧行为
以下旧行为不再作为当前实现:
- `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)

View File

@ -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/<SessionKey>`
- 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

View File

@ -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();

View File

@ -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(),

View File

@ -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,
);

View File

@ -773,8 +773,9 @@ class _AssistantPageState extends State<AssistantPage> {
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<AssistantPage> {
required SingleAgentProvider singleAgentProvider,
required AssistantPermissionLevel permissionLevel,
required String workspacePath,
required String remoteProjectRoot,
}) {
final attachmentBlock = attachmentNames.isEmpty
? ''
@ -993,9 +993,7 @@ class _AssistantPageState extends State<AssistantPage> {
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'

View File

@ -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';

View File

@ -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;

View File

@ -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: '',

View File

@ -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',
);
},
);

View File

@ -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(<String, Object>{});
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>[
AssistantThreadRecord(
sessionKey: 'draft:missing-ref-thread',
messages: const <GatewayChatMessage>[],
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<void>.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);
},
);
}