Align strict TaskThread workspace semantics

This commit is contained in:
Haitao Pan 2026-04-06 09:57:37 +08:00
parent f5a3826be8
commit 6efa70ee8b
16 changed files with 114 additions and 172 deletions

View File

@ -46,7 +46,9 @@ TaskThread
约束:
- `workspaceBinding` 是线程记录的一部分
- 它只在必要时更新
- 它只能在当前线程已完整时被显式更新
- 它不能用于 create first binding
- 它不能跨线程覆盖
- 它不再承担运行前 fallback 猜测语义
### 2.3 executionBinding
@ -100,7 +102,7 @@ flowchart LR
F --> G["执行结果"]
G --> H["回写线程上下文\n(主体区域 同步显示)"]
G --> I["必要时更新 workspaceBinding"]
G --> I["仅显式更新当前已完整线程的 workspaceBinding"]
H --> J["右栏显示"]
I --> J
@ -111,7 +113,8 @@ flowchart LR
- `读取 TaskThread` 是 UI 与执行层共享的唯一线程信息入口
- `构造执行请求` 在 agent-core / runtime 协调层完成
- `右栏显示` 明确依赖 `TaskThread` 当前记录
- `必要时更新 workspaceBinding` 是条件分支,不是固定步骤
- `workspaceBinding` 更新只允许发生在当前线程已完整的前提下
- `workspace_root` 不是线程身份,也不是运行时 override
## 4. UI 信息来源矩阵

View File

@ -178,9 +178,9 @@ flowchart LR
- [task-thread-session-key-isolation-20260329.md](task-thread-session-key-isolation-20260329.md)
补充“任务线必须先成为真实 `TaskThread/sessionKey`”的隔离约束,说明为什么 single-agent 的工作目录只能围绕当前线程身份解析。
- [assistant-thread-information-architecture.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-taskthread-docs-naming-cleanup/docs/architecture/assistant-thread-information-architecture.md)
- [assistant-thread-information-architecture.md](assistant-thread-information-architecture.md)
说明线程信息如何进入 UI、agent-core / runtime 请求构造、结果回写和右栏展示。
- [xworkmate-internal-state-architecture.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-taskthread-docs-naming-cleanup/docs/architecture/xworkmate-internal-state-architecture.md)
- [xworkmate-internal-state-architecture.md](xworkmate-internal-state-architecture.md)
说明控制器、状态存储和派生 UI 状态如何围绕 `TaskThread` 组织。
归档文档仍可保留作为历史背景,但不再参与当前设计说明。

View File

@ -229,12 +229,13 @@ Examples:
1. UI 选择 `threadId`
2. 控制器 / runtime 读取 `TaskThread`
3. 若线程字段缺失,才回退到 Settings 中心默认值用于初始化或补全
3. 若线程缺失或 `workspaceBinding` 不完整,则该线程视为非法或不可执行状态,必须显式失败或在恢复阶段跳过
这意味着:
- Settings 是默认值来源,不是当前线程真相源
- 当前线程的执行模式、模型、技能、工作空间都以 `TaskThread` 为准
- Settings 不能用于补全已存在线程的缺失字段
### 4.2 执行请求构造优先级
@ -249,7 +250,7 @@ Examples:
1. 回写 `contextState`
2. 主体区域同步显示
3. 必要时更新 `workspaceBinding`
3. 仅在当前线程已经完整时,显式更新该线程 `workspaceBinding`
4. 右栏读取最新 `TaskThread` 记录并刷新
## 5. Lifecycle Baseline
@ -273,7 +274,7 @@ flowchart LR
F --> G["执行结果"]
G --> H["回写线程上下文\n(主体区域 同步显示)"]
G --> I["必要时更新 workspaceBinding"]
G --> I["仅显式更新当前已完整线程的 workspaceBinding"]
H --> J["右栏显示"]
I --> J
@ -288,9 +289,9 @@ flowchart LR
## 6. 文档边界
- [assistant-thread-target-model-20260328.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-taskthread-docs-naming-cleanup/docs/architecture/assistant-thread-target-model-20260328.md)
- [assistant-thread-target-model-20260328.md](assistant-thread-target-model-20260328.md)
负责说明 `TaskThread` 当前模型与生命周期主链。
- [assistant-thread-information-architecture.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-taskthread-docs-naming-cleanup/docs/architecture/assistant-thread-information-architecture.md)
- [assistant-thread-information-architecture.md](assistant-thread-information-architecture.md)
负责说明线程信息如何进入 UI、请求构造与结果回写。
归档文档只保留为历史背景,不再作为当前内部状态设计依据。

View File

@ -304,7 +304,7 @@ flowchart LR
H --> I
I --> J["回写 contextState / lifecycleState"]
I --> K["必要时回写 workspaceBinding"]
I --> K["仅显式回写当前已完整线程的 workspaceBinding"]
J --> L["持久化 TaskThread"]
K --> L
@ -317,7 +317,8 @@ flowchart LR
- 线程先绑定,再执行
- 执行模式由 `executionBinding` 决定
- 结果先回写线程,再刷新 UI
- 远端返回新的 working directory 时,应该回写 `workspaceBinding`
- 远端返回新的 working directory 时,只能显式回写当前已完整线程的 `workspaceBinding`
- 这类回写不能创建 first binding也不能改变线程身份
## 当前代码里的真实组件映射

View File

@ -249,6 +249,10 @@ extension AppControllerDesktopThreadActions on AppController {
}) async {
final currentSessionKey = sessionsControllerInternal.currentSessionKey;
final currentTarget = assistantExecutionTargetForSession(currentSessionKey);
await ensureDesktopTaskThreadBindingInternal(
currentSessionKey,
executionTarget: currentTarget,
);
if (currentTarget == AssistantExecutionTarget.singleAgent ||
currentTarget == AssistantExecutionTarget.auto) {
await bootstrapThreadWorkspaceFromExecutionContextInternal(
@ -256,20 +260,9 @@ extension AppControllerDesktopThreadActions on AppController {
message,
);
}
await ensureDesktopTaskThreadBindingInternal(
currentSessionKey,
executionTarget: currentTarget,
);
var workspacePath = assistantWorkspacePathForSession(
currentSessionKey,
).trim();
if (workspacePath.isEmpty) {
await tryBindWorkspaceForOnlyChatFallbackInternal(
currentSessionKey,
currentTarget,
);
workspacePath = assistantWorkspacePathForSession(currentSessionKey).trim();
}
if (workspacePath.isEmpty) {
final error = StateError(
appText(
@ -545,6 +538,13 @@ extension AppControllerDesktopThreadActions on AppController {
);
final existing = assistantThreadRecordsInternal[normalizedSessionKey];
if (existing == null || !existing.workspaceBinding.isComplete) {
throw StateError(
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
);
}
final target = existing.executionTarget;
if (target != AssistantExecutionTarget.singleAgent &&
target != AssistantExecutionTarget.auto) {
return;
}
upsertTaskThreadInternal(
@ -594,5 +594,9 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> tryBindWorkspaceForOnlyChatFallbackInternal(
String sessionKey,
AssistantExecutionTarget currentTarget,
) async {}
) async {
throw StateError(
'tryBindWorkspaceForOnlyChatFallbackInternal is no longer supported.',
);
}
}

View File

@ -158,14 +158,14 @@ extension AppControllerDesktopThreadSessions on AppController {
sessionKey,
);
final record = assistantThreadRecordsInternal[normalizedSessionKey];
if (record != null) {
return record.workspaceKind == WorkspaceKind.localFs
? WorkspaceRefKind.localPath
: WorkspaceRefKind.remotePath;
if (record == null || !record.workspaceBinding.isComplete) {
throw StateError(
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
);
}
return defaultWorkspaceRefKindForTargetInternal(
assistantExecutionTargetForSession(normalizedSessionKey),
);
return record.workspaceKind == WorkspaceKind.localFs
? WorkspaceRefKind.localPath
: WorkspaceRefKind.remotePath;
}
String assistantWorkspaceDisplayPathForSession(String sessionKey) {

View File

@ -115,15 +115,18 @@ extension AppControllerWebGatewayRelay on AppController {
subjectId: '',
displayName: '',
),
workspaceBinding:
existing?.workspaceBinding ??
WorkspaceBinding(
workspaceId: sessionKey,
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '',
displayPath: '',
writable: true,
),
workspaceBinding: buildWebWorkspaceBindingInternal(
sessionKey,
ownerScope:
existing?.ownerScope ??
const ThreadOwnerScope(
realm: ThreadRealm.remote,
subjectType: ThreadSubjectType.user,
subjectId: '',
displayName: '',
),
existingBinding: existing?.workspaceBinding,
),
executionBinding:
existing?.executionBinding ??
ExecutionBinding(
@ -158,16 +161,12 @@ extension AppControllerWebGatewayRelay on AppController {
existing?.lifecycleState ??
const ThreadLifecycleState(
archived: false,
status: 'needs_workspace',
status: 'ready',
lastRunAtMs: null,
lastResultCode: null,
),
);
threadRecordsInternal[sessionKey] = next;
await ensureWebTaskThreadBindingInternal(
sessionKey,
executionTarget: next.executionTarget,
);
}
await persistThreadsInternal();
recomputeDerivedWorkspaceStateInternal();

View File

@ -105,7 +105,13 @@ extension AppControllerWebHelpers on AppController {
final target =
sanitizeTargetInternal(record.executionTarget) ??
AssistantExecutionTarget.singleAgent;
final workspacePath = record.workspacePath.trim();
final workspaceBinding = record.workspaceBinding;
if (!workspaceBinding.isComplete) {
throw StateError(
'TaskThread ${record.threadId} is missing a complete workspaceBinding.',
);
}
final workspacePath = workspaceBinding.workspacePath.trim();
return record.copyWith(
executionTarget: target,
title: record.title.trim().isEmpty
@ -113,12 +119,12 @@ extension AppControllerWebHelpers on AppController {
: record.title.trim(),
workspaceBinding: WorkspaceBinding(
workspaceId: record.threadId,
workspaceKind: WorkspaceKind.remoteFs,
workspaceKind: workspaceBinding.workspaceKind,
workspacePath: workspacePath,
displayPath: record.displayPath.trim().isEmpty
? workspacePath
: record.displayPath.trim(),
writable: record.workspaceBinding.writable,
writable: workspaceBinding.writable,
),
lifecycleState: record.lifecycleState.copyWith(status: 'ready'),
);
@ -303,11 +309,7 @@ extension AppControllerWebHelpers on AppController {
lastRunAtMs: null,
lastResultCode: null,
))
.copyWith(
status: workspaceBinding.workspacePath.trim().isEmpty
? 'needs_workspace'
: 'ready',
),
.copyWith(status: 'ready'),
executionTarget: resolvedTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);

View File

@ -68,12 +68,14 @@ extension AppControllerWebSessions on AppController {
WorkspaceRefKind assistantWorkspaceKindForSession(String sessionKey) {
final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey);
final record = threadRecordsInternal[normalizedSessionKey];
if (record != null) {
return record.workspaceKind == WorkspaceKind.localFs
? WorkspaceRefKind.localPath
: WorkspaceRefKind.remotePath;
if (record == null || !record.workspaceBinding.isComplete) {
throw StateError(
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
);
}
return WorkspaceRefKind.remotePath;
return record.workspaceKind == WorkspaceKind.localFs
? WorkspaceRefKind.localPath
: WorkspaceRefKind.remotePath;
}
String assistantWorkspaceDisplayPathForSession(String sessionKey) {
@ -560,12 +562,8 @@ extension AppControllerWebSessions on AppController {
if (existing != null) {
return existing;
}
final target =
sanitizeTargetInternal(settingsInternal.assistantExecutionTarget) ??
AssistantExecutionTarget.singleAgent;
final record = newRecordInternal(target: target);
threadRecordsInternal[record.threadId] = record;
currentSessionKeyInternal = record.threadId;
return record;
throw StateError(
'Current session $currentSessionKeyInternal has no TaskThread record.',
);
}
}

View File

@ -47,10 +47,6 @@ extension AppControllerWebWorkspace on AppController {
for (final record in records) {
final sanitized = sanitizeRecordInternal(record);
threadRecordsInternal[sanitized.sessionKey] = sanitized;
await ensureWebTaskThreadBindingInternal(
sanitized.sessionKey,
executionTarget: sanitized.executionTarget,
);
}
if (threadRecordsInternal.isEmpty) {
final record = newRecordInternal(
@ -58,10 +54,6 @@ extension AppControllerWebWorkspace on AppController {
title: appText('新对话', 'New conversation'),
);
threadRecordsInternal[record.sessionKey] = record;
await ensureWebTaskThreadBindingInternal(
record.sessionKey,
executionTarget: record.executionTarget,
);
}
final preferredSession = normalizedSessionKeyInternal(
settingsInternal.assistantLastSessionKey,

View File

@ -60,13 +60,15 @@ class RemoteWebSessionRepository implements WebSessionRepository {
Map<String, dynamic> map => map['threads'] as List<dynamic>? ?? const [],
_ => const <dynamic>[],
};
return rawThreads
.whereType<Map>()
.map(
(item) =>
TaskThread.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
final records = <TaskThread>[];
for (final item in rawThreads.whereType<Map>()) {
try {
records.add(TaskThread.fromJson(item.cast<String, dynamic>()));
} catch (_) {
continue;
}
}
return List<TaskThread>.unmodifiable(records);
}
@override

View File

@ -61,10 +61,15 @@ class WebStore {
await clearTaskThreadState();
return const <TaskThread>[];
}
return threads
.whereType<Map>()
.map((item) => TaskThread.fromJson(item.cast<String, dynamic>()))
.toList(growable: false);
final records = <TaskThread>[];
for (final item in threads.whereType<Map>()) {
try {
records.add(TaskThread.fromJson(item.cast<String, dynamic>()));
} catch (_) {
continue;
}
}
return List<TaskThread>.unmodifiable(records);
} catch (_) {
await clearTaskThreadState();
return const <TaskThread>[];

View File

@ -210,7 +210,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
);
test(
'AppController bootstraps the current thread workspace from execution context before single-agent send',
'AppController updates the current thread workspace from execution context for local single-agent threads',
() async {
final tempDirectory = await createTempDirectoryInternal(
'xworkmate-single-agent-workspace-bootstrap-',
@ -221,7 +221,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
final store = createStoreFromTempDirectoryInternal(tempDirectory);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(workspacePath: ''),
SettingsSnapshot.defaults().copyWith(workspacePath: tempDirectory.path),
);
final client = FakeGoAgentCoreClientInternal(
capabilities: GoAgentCoreCapabilities(
@ -527,7 +527,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
await controller.settingsController.saveAiGatewayApiKey('live-key');
await controller.saveSettings(
controller.settings.copyWith(
workspacePath: '',
workspacePath: tempDirectory.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: server.baseUrl,
availableModels: const <String>['moonshotai/kimi-k2.5'],

View File

@ -472,7 +472,7 @@ void main() {
);
test(
'AppController rebinds the current single-agent thread after configuring a workspace root',
'AppController keeps the current single-agent thread workspace stable when saving workspace settings',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -494,46 +494,14 @@ void main() {
);
await store.initialize();
await store.saveSettingsSnapshot(
seededSettingsSnapshot(workspacePath: ''),
seededSettingsSnapshot(workspacePath: workspaceRoot.path),
);
final controller = AppController(store: store);
addTearDown(controller.dispose);
await waitForControllerInternal(controller);
final existingMain = controller
.assistantThreadRecordsInternal[controller.currentSessionKey]!;
controller.assistantThreadRecordsInternal[controller.currentSessionKey] =
existingMain.copyWith(
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '',
displayPath: '',
writable: true,
),
lifecycleState: existingMain.lifecycleState.copyWith(
status: 'needs_workspace',
),
executionTarget: AssistantExecutionTarget.singleAgent,
);
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'),
);
final derivedBeforeSave = controller.assistantWorkspacePathForSession(
controller.currentSessionKey,
);
@ -544,7 +512,7 @@ void main() {
.assistantThreadRecordsInternal[controller.currentSessionKey]
?.lifecycleState
.status,
'needs_workspace',
'ready',
);
await controller.saveSettingsDraft(
@ -564,7 +532,7 @@ void main() {
controller.assistantWorkspacePathForSession(
controller.currentSessionKey,
),
'${workspaceRoot.path}/.xworkmate/threads/main',
derivedBeforeSave,
);
expect(controller.hasPendingSettingsApply, isFalse);
expect(controller.hasSettingsDraftChanges, isFalse);
@ -572,7 +540,7 @@ void main() {
controller
.assistantThreadRecordsInternal[controller.currentSessionKey]
?.displayPath,
'${workspaceRoot.path}/.xworkmate/threads/main',
derivedBeforeSave,
);
expect(
controller
@ -585,7 +553,7 @@ void main() {
);
test(
'AppController derives a thread workspace path when single-agent binding is empty but workspace root is configured',
'AppController rejects missing workspace bindings when reading workspace kind',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -616,33 +584,20 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
final existingMain =
controller.assistantThreadRecordsInternal[controller.currentSessionKey]!;
controller.assistantThreadRecordsInternal[controller.currentSessionKey] =
existingMain.copyWith(
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '',
displayPath: '',
writable: true,
),
lifecycleState: existingMain.lifecycleState.copyWith(
status: 'needs_workspace',
),
);
controller.assistantThreadRecordsInternal.remove(
controller.currentSessionKey,
);
expect(
controller.assistantWorkspacePathForSession(
() => controller.assistantWorkspaceKindForSession(
controller.currentSessionKey,
),
'${workspaceRoot.path}/.xworkmate/threads/main',
throwsA(isA<StateError>()),
);
},
);
test(
'AppController keeps single-agent threads unbound when the workspace root cannot create thread directories',
'AppController fails fast when a single-agent thread cannot allocate a writable workspace',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -674,30 +629,12 @@ void main() {
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.assistantWorkspacePathForSession('draft:invalid-root'),
isEmpty,
);
expect(
controller
.assistantThreadRecordsInternal['draft:invalid-root']
?.lifecycleState
.status,
'needs_workspace',
);
await expectLater(
() => controller.sendChatMessage('请输出 SHOULD_NOT_RUN', thinking: 'low'),
() async => controller.initializeAssistantThreadContext(
'draft:invalid-root',
title: 'Invalid Root',
executionTarget: AssistantExecutionTarget.singleAgent,
),
throwsA(isA<StateError>()),
);
},

View File

@ -299,7 +299,7 @@ void registerSecureConfigStoreSuiteCompatibilityTestsInternal() {
},
'lifecycleState': const <String, Object?>{
'archived': false,
'status': 'needs_workspace',
'status': 'ready',
'lastRunAtMs': null,
'lastResultCode': null,
},

View File

@ -9,7 +9,7 @@ TaskThread buildTaskThreadFixture({
AssistantExecutionTarget executionTarget =
AssistantExecutionTarget.singleAgent,
SingleAgentProvider singleAgentProvider = SingleAgentProvider.auto,
String workspacePath = '',
String workspacePath = '/tmp/task-thread-fixture',
WorkspaceKind workspaceKind = WorkspaceKind.localFs,
bool writable = true,
String? displayPath,
@ -29,9 +29,7 @@ TaskThread buildTaskThreadFixture({
String? lastResultCode,
}) {
final normalizedDisplayPath = displayPath ?? workspacePath;
final normalizedStatus =
lifecycleStatus ??
(workspacePath.trim().isEmpty ? 'needs_workspace' : 'ready');
final normalizedStatus = lifecycleStatus ?? 'ready';
return TaskThread(
threadId: threadId,
title: title,