Isolate assistant thread working directories

This commit is contained in:
Haitao Pan 2026-03-26 18:25:41 +08:00
parent 5a7dbe872b
commit 68cdbe4e4b
3 changed files with 253 additions and 23 deletions

View File

@ -1272,11 +1272,75 @@ class AppController extends ChangeNotifier {
final target = assistantExecutionTargetForSession(normalizedSessionKey);
return switch (target) {
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent =>
_defaultLocalWorkspaceRefForSession(normalizedSessionKey),
};
}
String _defaultLocalWorkspaceRefForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final baseWorkspace = settings.workspacePath.trim();
if (baseWorkspace.isEmpty || normalizedSessionKey == 'main') {
return baseWorkspace;
}
final threadWorkspace =
'${_trimTrailingPathSeparator(baseWorkspace)}/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}';
_ensureLocalWorkspaceDirectory(threadWorkspace);
return threadWorkspace;
}
String _threadWorkspaceDirectoryName(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final sanitized = normalizedSessionKey
.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '-')
.replaceAll(RegExp(r'-{2,}'), '-')
.replaceAll(RegExp(r'^[-.]+|[-.]+$'), '');
return sanitized.isEmpty ? 'thread' : sanitized;
}
String _trimTrailingPathSeparator(String path) {
if (path.endsWith('/') && path.length > 1) {
return path.substring(0, path.length - 1);
}
return path;
}
void _ensureLocalWorkspaceDirectory(String path) {
final normalizedPath = path.trim();
if (normalizedPath.isEmpty) {
return;
}
try {
Directory(normalizedPath).createSync(recursive: true);
} catch (_) {
// Best effort only. The caller can still decide whether to use fallback behavior.
}
}
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;
}
return workspaceRefKind == WorkspaceRefKind.localPath &&
normalizedRef == settings.workspacePath.trim();
}
WorkspaceRefKind _defaultWorkspaceRefKindForTarget(
AssistantExecutionTarget target,
) {
@ -1305,7 +1369,13 @@ class AppController extends ChangeNotifier {
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
if (existing != null &&
existingWorkspaceRef.isNotEmpty &&
existing.workspaceRefKind == nextWorkspaceRefKind) {
existing.workspaceRefKind == nextWorkspaceRefKind &&
!_usesLegacySharedWorkspaceRef(
normalizedSessionKey,
executionTarget: resolvedTarget,
workspaceRef: existingWorkspaceRef,
workspaceRefKind: existing.workspaceRefKind,
)) {
return;
}
if (existing != null &&
@ -2201,11 +2271,7 @@ class AppController extends ChangeNotifier {
singleAgentProvider:
singleAgentProvider ??
singleAgentProviderForSession(currentSessionKey),
workspaceRef: switch (resolvedTarget) {
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
},
workspaceRef: _defaultWorkspaceRefForSession(normalizedSessionKey),
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
@ -3379,15 +3445,20 @@ class AppController extends ChangeNotifier {
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
final features = featuresFor(_hostUiFeaturePlatform);
final allowedNavigation = normalizeAssistantNavigationDestinations(
snapshot.assistantNavigationDestinations,
).where((entry) {
final destination = entry.destination;
if (destination != null) {
return features.allowedDestinations.contains(destination);
}
return features.allowedDestinations.contains(WorkspaceDestination.settings);
}).toList(growable: false);
final allowedNavigation =
normalizeAssistantNavigationDestinations(
snapshot.assistantNavigationDestinations,
)
.where((entry) {
final destination = entry.destination;
if (destination != null) {
return features.allowedDestinations.contains(destination);
}
return features.allowedDestinations.contains(
WorkspaceDestination.settings,
);
})
.toList(growable: false);
final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
snapshot.assistantExecutionTarget,
);
@ -4522,6 +4593,15 @@ class AppController extends ChangeNotifier {
continue;
}
final titleFromSettings = assistantCustomTaskTitle(sessionKey);
final shouldMigrateWorkspaceRef =
record.workspaceRef.trim().isEmpty ||
_usesLegacySharedWorkspaceRef(
sessionKey,
executionTarget:
record.executionTarget ?? settings.assistantExecutionTarget,
workspaceRef: record.workspaceRef,
workspaceRefKind: record.workspaceRefKind,
);
final normalizedRecord = record.copyWith(
sessionKey: sessionKey,
title: titleFromSettings.isEmpty
@ -4547,10 +4627,10 @@ class AppController extends ChangeNotifier {
record.executionTarget ?? settings.assistantExecutionTarget,
)
: record.gatewayEntryState,
workspaceRef: record.workspaceRef.trim().isEmpty
workspaceRef: shouldMigrateWorkspaceRef
? _defaultWorkspaceRefForSession(sessionKey)
: record.workspaceRef.trim(),
workspaceRefKind: record.workspaceRef.trim().isEmpty
workspaceRefKind: shouldMigrateWorkspaceRef
? _defaultWorkspaceRefKindForTarget(
record.executionTarget ?? settings.assistantExecutionTarget,
)
@ -4824,7 +4904,10 @@ class AppController extends ChangeNotifier {
gatewayEntryState ??
existing?.gatewayEntryState ??
_gatewayEntryStateForTarget(nextExecutionTarget),
workspaceRef: workspaceRef ?? existing?.workspaceRef ?? '',
workspaceRef:
workspaceRef ??
existing?.workspaceRef ??
_defaultWorkspaceRefForSession(normalizedSessionKey),
workspaceRefKind:
workspaceRefKind ??
existing?.workspaceRefKind ??

View File

@ -630,6 +630,88 @@ void main() {
);
},
);
test(
'AppController uses an isolated workspace for draft Single Agent threads',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-single-agent-isolated-thread-cwd-',
);
final defaultWorkspace = Directory(
'${tempDirectory.path}/default-workspace',
);
await defaultWorkspace.create(recursive: true);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
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: defaultWorkspace.path,
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
),
);
final runner = _FakeSingleAgentRunner(
resolvedProvider: SingleAgentProvider.codex,
result: const SingleAgentRunResult(
provider: SingleAgentProvider.codex,
output: 'THREAD_OK',
success: true,
errorMessage: '',
shouldFallbackToAiChat: false,
),
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
codex: _FakeCodexRuntime(),
),
singleAgentRunner: runner,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
controller.initializeAssistantThreadContext(
'draft:artifact-thread',
title: 'Artifact Thread',
executionTarget: AssistantExecutionTarget.singleAgent,
);
await controller.switchSession('draft:artifact-thread');
await controller.sendChatMessage('检查当前线程目录', thinking: 'low');
const expectedWorkspaceSuffix =
'.xworkmate/threads/draft-artifact-thread';
expect(runner.runCalls, 1);
expect(
runner.lastRequest?.workingDirectory,
'${defaultWorkspace.path}/$expectedWorkspaceSuffix',
);
expect(
controller.assistantWorkspaceRefForSession('draft:artifact-thread'),
'${defaultWorkspace.path}/$expectedWorkspaceSuffix',
);
expect(
Directory(
'${defaultWorkspace.path}/$expectedWorkspaceSuffix',
).existsSync(),
isTrue,
);
},
);
}
class _FakeGatewayRuntime extends GatewayRuntime {

View File

@ -65,10 +65,14 @@ void main() {
executionTarget: AssistantExecutionTarget.singleAgent,
);
await controller.switchSession(draftKey);
expect(
controller.assistantWorkspaceRefForSession(draftKey),
controller.settings.workspacePath,
final draftWorkspaceRef = controller.assistantWorkspaceRefForSession(
draftKey,
);
expect(
draftWorkspaceRef,
startsWith('${controller.settings.workspacePath}/.xworkmate/threads/'),
);
expect(draftWorkspaceRef, isNot(controller.settings.workspacePath));
expect(
controller.assistantWorkspaceRefKindForSession(draftKey),
WorkspaceRefKind.localPath,
@ -76,6 +80,67 @@ void main() {
},
);
test(
'AppController migrates draft single-agent threads off the shared workspace root',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-thread-workspace-migrate-',
);
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:artifact-thread',
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: 'Artifact Thread',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
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:artifact-thread',
);
expect(
migratedWorkspace,
'${workspaceRoot.path}/.xworkmate/threads/draft-artifact-thread',
);
expect(Directory(migratedWorkspace).existsSync(), isTrue);
},
);
test(
'AppController preserves recorded workspace refs when switching threads',
() async {