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); final target = assistantExecutionTargetForSession(normalizedSessionKey);
return switch (target) { return switch (target) {
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(), AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local || AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent =>
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(), _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( WorkspaceRefKind _defaultWorkspaceRefKindForTarget(
AssistantExecutionTarget target, AssistantExecutionTarget target,
) { ) {
@ -1305,7 +1369,13 @@ class AppController extends ChangeNotifier {
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? ''; final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
if (existing != null && if (existing != null &&
existingWorkspaceRef.isNotEmpty && existingWorkspaceRef.isNotEmpty &&
existing.workspaceRefKind == nextWorkspaceRefKind) { existing.workspaceRefKind == nextWorkspaceRefKind &&
!_usesLegacySharedWorkspaceRef(
normalizedSessionKey,
executionTarget: resolvedTarget,
workspaceRef: existingWorkspaceRef,
workspaceRefKind: existing.workspaceRefKind,
)) {
return; return;
} }
if (existing != null && if (existing != null &&
@ -2201,11 +2271,7 @@ class AppController extends ChangeNotifier {
singleAgentProvider: singleAgentProvider:
singleAgentProvider ?? singleAgentProvider ??
singleAgentProviderForSession(currentSessionKey), singleAgentProviderForSession(currentSessionKey),
workspaceRef: switch (resolvedTarget) { workspaceRef: _defaultWorkspaceRefForSession(normalizedSessionKey),
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
},
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget), workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
); );
@ -3379,15 +3445,20 @@ class AppController extends ChangeNotifier {
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) { SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
final features = featuresFor(_hostUiFeaturePlatform); final features = featuresFor(_hostUiFeaturePlatform);
final allowedNavigation = normalizeAssistantNavigationDestinations( final allowedNavigation =
snapshot.assistantNavigationDestinations, normalizeAssistantNavigationDestinations(
).where((entry) { snapshot.assistantNavigationDestinations,
final destination = entry.destination; )
if (destination != null) { .where((entry) {
return features.allowedDestinations.contains(destination); final destination = entry.destination;
} if (destination != null) {
return features.allowedDestinations.contains(WorkspaceDestination.settings); return features.allowedDestinations.contains(destination);
}).toList(growable: false); }
return features.allowedDestinations.contains(
WorkspaceDestination.settings,
);
})
.toList(growable: false);
final sanitizedExecutionTarget = features.sanitizeExecutionTarget( final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
snapshot.assistantExecutionTarget, snapshot.assistantExecutionTarget,
); );
@ -4522,6 +4593,15 @@ class AppController extends ChangeNotifier {
continue; continue;
} }
final titleFromSettings = assistantCustomTaskTitle(sessionKey); 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( final normalizedRecord = record.copyWith(
sessionKey: sessionKey, sessionKey: sessionKey,
title: titleFromSettings.isEmpty title: titleFromSettings.isEmpty
@ -4547,10 +4627,10 @@ class AppController extends ChangeNotifier {
record.executionTarget ?? settings.assistantExecutionTarget, record.executionTarget ?? settings.assistantExecutionTarget,
) )
: record.gatewayEntryState, : record.gatewayEntryState,
workspaceRef: record.workspaceRef.trim().isEmpty workspaceRef: shouldMigrateWorkspaceRef
? _defaultWorkspaceRefForSession(sessionKey) ? _defaultWorkspaceRefForSession(sessionKey)
: record.workspaceRef.trim(), : record.workspaceRef.trim(),
workspaceRefKind: record.workspaceRef.trim().isEmpty workspaceRefKind: shouldMigrateWorkspaceRef
? _defaultWorkspaceRefKindForTarget( ? _defaultWorkspaceRefKindForTarget(
record.executionTarget ?? settings.assistantExecutionTarget, record.executionTarget ?? settings.assistantExecutionTarget,
) )
@ -4824,7 +4904,10 @@ class AppController extends ChangeNotifier {
gatewayEntryState ?? gatewayEntryState ??
existing?.gatewayEntryState ?? existing?.gatewayEntryState ??
_gatewayEntryStateForTarget(nextExecutionTarget), _gatewayEntryStateForTarget(nextExecutionTarget),
workspaceRef: workspaceRef ?? existing?.workspaceRef ?? '', workspaceRef:
workspaceRef ??
existing?.workspaceRef ??
_defaultWorkspaceRefForSession(normalizedSessionKey),
workspaceRefKind: workspaceRefKind:
workspaceRefKind ?? workspaceRefKind ??
existing?.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 { class _FakeGatewayRuntime extends GatewayRuntime {

View File

@ -65,10 +65,14 @@ void main() {
executionTarget: AssistantExecutionTarget.singleAgent, executionTarget: AssistantExecutionTarget.singleAgent,
); );
await controller.switchSession(draftKey); await controller.switchSession(draftKey);
expect( final draftWorkspaceRef = controller.assistantWorkspaceRefForSession(
controller.assistantWorkspaceRefForSession(draftKey), draftKey,
controller.settings.workspacePath,
); );
expect(
draftWorkspaceRef,
startsWith('${controller.settings.workspacePath}/.xworkmate/threads/'),
);
expect(draftWorkspaceRef, isNot(controller.settings.workspacePath));
expect( expect(
controller.assistantWorkspaceRefKindForSession(draftKey), controller.assistantWorkspaceRefKindForSession(draftKey),
WorkspaceRefKind.localPath, 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( test(
'AppController preserves recorded workspace refs when switching threads', 'AppController preserves recorded workspace refs when switching threads',
() async { () async {