fix(runtime): keep invalid single-agent roots unbound

This commit is contained in:
Haitao Pan 2026-03-29 12:43:45 +08:00
parent 7780423e81
commit ca825bba73
2 changed files with 108 additions and 40 deletions

View File

@ -58,8 +58,9 @@ extension AppControllerDesktopThreadBinding on AppController {
}
final threadWorkspace =
'${trimTrailingPathSeparatorInternal(baseWorkspace)}/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(normalizedSessionKey)}';
ensureLocalWorkspaceDirectoryInternal(threadWorkspace);
return threadWorkspace;
return ensureLocalWorkspaceDirectoryInternal(threadWorkspace)
? threadWorkspace
: '';
}
String remoteThreadWorkspacePathInternal(
@ -93,16 +94,17 @@ extension AppControllerDesktopThreadBinding on AppController {
return path;
}
void ensureLocalWorkspaceDirectoryInternal(String path) {
bool ensureLocalWorkspaceDirectoryInternal(String path) {
final normalizedPath = path.trim();
if (normalizedPath.isEmpty) {
return;
return false;
}
try {
Directory(normalizedPath).createSync(recursive: true);
} catch (_) {
// Best effort only. The caller can still decide whether to fail fast.
}
return Directory(normalizedPath).existsSync();
}
ThreadOwnerScope desktopThreadOwnerScopeFromIdentityInternal(
@ -141,10 +143,13 @@ extension AppControllerDesktopThreadBinding on AppController {
if (existingBinding != null &&
existingBinding.workspacePath.trim().isNotEmpty) {
if (existingBinding.workspaceKind == WorkspaceKind.localFs) {
ensureLocalWorkspaceDirectoryInternal(existingBinding.workspacePath);
return existingBinding.copyWith(
displayPath: existingBinding.workspacePath,
);
if (ensureLocalWorkspaceDirectoryInternal(
existingBinding.workspacePath,
)) {
return existingBinding.copyWith(
displayPath: existingBinding.workspacePath,
);
}
}
final defaultRemotePath = remoteThreadWorkspacePathInternal(
sessionKey,
@ -205,8 +210,7 @@ extension AppControllerDesktopThreadBinding on AppController {
executionMode: switch (executionTarget) {
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local =>
ThreadExecutionMode.gatewayLocal,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
AssistantExecutionTarget.remote =>
ThreadExecutionMode.gatewayRemote,
},
@ -249,14 +253,15 @@ extension AppControllerDesktopThreadBinding on AppController {
existing?.singleAgentProvider ?? SingleAgentProvider.auto,
existingBinding: existing?.executionBinding,
),
lifecycleState: (existing?.lifecycleState ??
const ThreadLifecycleState(
archived: false,
status: 'ready',
lastRunAtMs: null,
lastResultCode: null,
))
.copyWith(status: lifecycleStatus),
lifecycleState:
(existing?.lifecycleState ??
const ThreadLifecycleState(
archived: false,
status: 'ready',
lastRunAtMs: null,
lastResultCode: null,
))
.copyWith(status: lifecycleStatus),
executionTarget: resolvedExecutionTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);

View File

@ -66,8 +66,8 @@ void main() {
AssistantExecutionTarget.remote,
);
final record =
controller.assistantThreadRecordsInternal[controller.currentSessionKey]!;
final record = controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!;
expect(record.ownerScope.realm, ThreadRealm.local);
expect(record.ownerScope.subjectType, ThreadSubjectType.user);
expect(record.ownerScope.subjectId, isNotEmpty);
@ -485,8 +485,8 @@ void main() {
final controller = AppController(store: store);
addTearDown(controller.dispose);
await waitForControllerInternal(controller);
final existingMain =
controller.assistantThreadRecordsInternal[controller.currentSessionKey]!;
final existingMain = controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!;
controller.assistantThreadRecordsInternal[controller.currentSessionKey] =
existingMain.copyWith(
workspaceBinding: const WorkspaceBinding(
@ -504,25 +504,27 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
controller.assistantThreadRecordsInternal[controller.currentSessionKey] =
controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!
.copyWith(
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '',
displayPath: '',
writable: true,
),
lifecycleState: controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!
.lifecycleState
.copyWith(status: 'needs_workspace'),
);
controller.assistantThreadRecordsInternal[controller
.currentSessionKey] = controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!
.copyWith(
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '',
displayPath: '',
writable: true,
),
lifecycleState: controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!
.lifecycleState
.copyWith(status: 'needs_workspace'),
);
expect(
controller.assistantWorkspaceRefForSession(controller.currentSessionKey),
controller.assistantWorkspaceRefForSession(
controller.currentSessionKey,
),
isEmpty,
);
expect(
@ -538,7 +540,9 @@ void main() {
);
expect(
controller.assistantWorkspaceRefForSession(controller.currentSessionKey),
controller.assistantWorkspaceRefForSession(
controller.currentSessionKey,
),
'${workspaceRoot.path}/.xworkmate/threads/main',
);
expect(
@ -556,4 +560,63 @@ void main() {
);
},
);
test(
'AppController keeps single-agent threads unbound when the workspace root cannot create thread directories',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-thread-workspace-invalid-root-',
);
final invalidRootFile = File('${tempDirectory.path}/workspace-root-file');
await invalidRootFile.writeAsString('not-a-directory');
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: invalidRootFile.path,
),
);
final controller = AppController(store: store);
addTearDown(controller.dispose);
await waitForControllerInternal(controller);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
controller.initializeAssistantThreadContext(
'draft:invalid-root',
title: 'Invalid Root',
executionTarget: AssistantExecutionTarget.singleAgent,
);
final expectedThreadWorkspace = Directory(
'${invalidRootFile.path}/.xworkmate/threads/draft-invalid-root',
);
expect(await expectedThreadWorkspace.exists(), isFalse);
expect(
controller.assistantWorkspaceRefForSession('draft:invalid-root'),
isEmpty,
);
expect(
controller
.assistantThreadRecordsInternal['draft:invalid-root']
?.lifecycleState
.status,
'needs_workspace',
);
},
);
}