324 lines
9.9 KiB
Markdown
324 lines
9.9 KiB
Markdown
# TaskThread SessionKey 隔离修正(2026-03-29)
|
||
|
||
术语说明:
|
||
|
||
- 本文写于 `single-agent` 仍是主术语的阶段;凡正文出现 `single-agent`,当前都应读作任务对话模式下的 `agent` 一级目标
|
||
- 当前任务对话框 provider 选择与 target/catalog 真源口径,以 [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) 为准
|
||
|
||
本文补充并修正 XWorkmate 当前“任务线 / 线程 / 工作目录”设计中的一个关键约束:
|
||
|
||
- 左侧任务线不能只是派生 UI 项
|
||
- 每个可执行任务线都必须先落成真实 `TaskThread`
|
||
- 每个可执行任务线都必须绑定独立、稳定的 `sessionKey`
|
||
- single-agent 的 `workingDirectory` 只能从该 `sessionKey` 对应的 `TaskThread.workspaceBinding` 读取
|
||
|
||
本文是对现有 `TaskThread` 主模型的补充,不替代:
|
||
|
||
- `task-control-plane-unification.md`
|
||
|
||
## 1. 问题定义
|
||
|
||
当前实现里,single-agent 可执行工作目录实际由以下链路决定:
|
||
|
||
```text
|
||
currentSessionKey
|
||
-> normalizedAssistantSessionKeyInternal(sessionKey)
|
||
-> assistantWorkspacePathForSession(sessionKey)
|
||
-> resolveSingleAgentWorkingDirectoryForSessionInternal(sessionKey)
|
||
-> GoTaskServiceRequest.workingDirectory
|
||
```
|
||
|
||
这条链路说明:
|
||
|
||
1. 真正决定执行目录的是 `sessionKey`
|
||
2. prompt 文本不会创建 first binding,也不会覆盖当前线程绑定
|
||
3. 空 `sessionKey` 会被归一为 `main`
|
||
4. 一旦多个任务线没有真正切换到独立 `sessionKey`,它们就会共享 `main`
|
||
|
||
因此,现象上即使 UI 展示出多个“任务线”,底层也可能仍然只有一条真正可执行线程:
|
||
|
||
- `main`
|
||
- `settings.workspacePath/.xworkmate/threads/main`
|
||
|
||
这会直接导致:
|
||
|
||
- 每个任务线没有自己的本地工作目录
|
||
- single-agent 请求继续复用 `threads/main`
|
||
- 右栏显示、消息上下文、技能选择和工作区记录都可能混叠到同一线程
|
||
|
||
## 2. 修正结论
|
||
|
||
本轮设计修正后的主结论如下:
|
||
|
||
1. “任务线”不是纯展示概念,而是 `TaskThread` 的 UI 呈现。
|
||
2. 每个可执行任务线必须有自己的 `TaskThread.threadId`。
|
||
3. `TaskThread.threadId` 就是该任务线的 `sessionKey`。
|
||
4. 任何可触发执行的入口都不得在空 `sessionKey` 下运行。
|
||
5. 对非主线程任务线,禁止 silent fallback 到 `main`。
|
||
6. prompt `workspace_root` 不是线程身份,也不再参与主链更新;`workspaceBinding` 只能由显式 create/load 绑定或结构化执行结果回写更新。
|
||
|
||
换句话说:
|
||
|
||
```text
|
||
Task Line == TaskThread == sessionKey == threadId
|
||
```
|
||
|
||
只要这四者没有对齐,任务线隔离就是伪隔离。
|
||
|
||
## 3. 设计目标
|
||
|
||
本修正要保证以下目标:
|
||
|
||
### 3.1 身份目标
|
||
|
||
- 每个任务线都有稳定身份
|
||
- 该身份可持久化、可恢复、可切换
|
||
- UI 当前选中的永远是 `TaskThread.threadId`
|
||
|
||
### 3.2 执行目标
|
||
|
||
- single-agent 执行只读取当前线程自己的 `workspaceBinding.workspacePath`
|
||
- 不允许从消息文本、派生视图或全局临时状态猜测目标线程
|
||
- 不允许把未绑定线程默认送到 `main`
|
||
|
||
### 3.3 信息一致性目标
|
||
|
||
- 左栏任务线
|
||
- 主体消息区
|
||
- 右栏工作路径
|
||
- 技能选择
|
||
- provider 返回回写
|
||
|
||
以上信息必须围绕同一个 `sessionKey` 聚合。
|
||
|
||
## 4. 核心约束
|
||
|
||
### 4.1 任务线创建约束
|
||
|
||
创建任务线时,必须先分配唯一 `sessionKey`,再允许进入 UI 可执行状态。
|
||
|
||
推荐形式:
|
||
|
||
```text
|
||
draft:<timestamp-or-ulid>
|
||
agent:<agentId>:<baseKey>
|
||
```
|
||
|
||
禁止:
|
||
|
||
- 仅创建 UI 派生卡片,不创建 `TaskThread`
|
||
- 创建后仍停留在空 key
|
||
- 通过“后面再补 sessionKey”的方式进入可执行状态
|
||
|
||
### 4.2 任务线切换约束
|
||
|
||
用户切换任务线时,必须先完成:
|
||
|
||
```text
|
||
UI selection
|
||
-> switchSession(sessionKey)
|
||
-> ensureDesktopTaskThreadBindingInternal(sessionKey)
|
||
-> read TaskThread(sessionKey)
|
||
-> allow execute
|
||
```
|
||
|
||
禁止:
|
||
|
||
- 任务线视觉上切换了,但 `currentSessionKey` 没变
|
||
- 任务线详情来自 A,执行请求却仍发送到 `main`
|
||
|
||
### 4.3 single-agent 执行约束
|
||
|
||
single-agent 请求必须满足:
|
||
|
||
```text
|
||
request.sessionId == request.threadId == current TaskThread.threadId
|
||
request.workingDirectory == current TaskThread.workspaceBinding.workspacePath
|
||
```
|
||
|
||
禁止:
|
||
|
||
- 从 prompt 文本中提取 `workspace_root` 或其他 side-channel 直接替代线程身份
|
||
- 因 `sessionKey` 缺失而自动转发到 `main`
|
||
- 因右栏展示路径存在而绕过线程绑定
|
||
|
||
### 4.4 fallback 约束
|
||
|
||
`main` 只能作为“主线程身份”,不能作为“任何未绑定线程的兜底线程”。
|
||
|
||
因此:
|
||
|
||
- 空 `sessionKey -> main` 只允许用于真正的主线程初始化阶段
|
||
- 非主线程任务线若缺少 `sessionKey`,状态应为 `needs_binding` 或 `not_runnable`
|
||
- 本地可执行线程在 create/load 阶段必须已经拥有唯一工作目录
|
||
- 不允许继续执行并偷偷落到 `threads/main`
|
||
|
||
## 5. Workspace 更新的正确角色
|
||
|
||
运行期 workspace 更新必须通过结构化数据进入主链。
|
||
|
||
它不是:
|
||
|
||
- prompt 文本中的 `workspace_root`
|
||
- 线程身份
|
||
- session 选择器
|
||
- 运行时对当前线程的隐式覆盖命令
|
||
|
||
它只能是:
|
||
|
||
- create/load 阶段对当前线程 `workspaceBinding` 的显式绑定
|
||
- 外部 provider / transport 返回的结构化字段(例如 `resolvedWorkingDirectory` 与 `resolvedWorkspaceRefKind`)对当前线程 binding 的确认更新
|
||
|
||
因此正确顺序应为:
|
||
|
||
```text
|
||
Structured execution result
|
||
-> update current TaskThread.workspaceBinding
|
||
-> persist on that TaskThread
|
||
-> subsequent execute reads TaskThread.workspaceBinding.workspacePath
|
||
```
|
||
|
||
而不是:
|
||
|
||
```text
|
||
Prompt text side-channel
|
||
-> bypass thread binding
|
||
-> directly becomes runtime workingDirectory
|
||
```
|
||
|
||
## 6. 修正后的信息流
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["创建任务线"] --> B["分配唯一 sessionKey"]
|
||
B --> C["创建 / 更新 TaskThread"]
|
||
C --> D["写入 workspaceBinding / executionBinding / contextState"]
|
||
D --> E["任务线进入可见列表"]
|
||
|
||
E --> F["用户切换任务线"]
|
||
F --> G["switchSession(sessionKey)"]
|
||
G --> H["currentSessionKey = TaskThread.threadId"]
|
||
H --> I["ensureDesktopTaskThreadBindingInternal(sessionKey)"]
|
||
|
||
I --> J["读取当前 TaskThread"]
|
||
J --> K["workspaceBinding.workspacePath"]
|
||
J --> L["executionBinding"]
|
||
J --> M["contextState"]
|
||
|
||
K --> N["GoTaskServiceRequest.workingDirectory"]
|
||
L --> O["provider / execution mode"]
|
||
M --> P["messages / model / skills"]
|
||
|
||
N --> Q["single-agent execute"]
|
||
O --> Q
|
||
P --> Q
|
||
|
||
Q --> R["provider 返回结果"]
|
||
R --> S["仅回写同一 sessionKey 的 TaskThread"]
|
||
```
|
||
|
||
## 7. 模块修正要求
|
||
|
||
### 7.1 UI / 任务列表
|
||
|
||
任务列表中的每个可点击项都必须满足:
|
||
|
||
- 拥有真实 `sessionKey`
|
||
- 该 key 可被 `switchSession(sessionKey)` 命中
|
||
- 该 key 能在 `assistantThreadRecordsInternal` 中找到或即时创建对应 `TaskThread`
|
||
|
||
如果某个列表项没有真实 `sessionKey`,它只能是只读提示项,不能作为可执行任务线。
|
||
|
||
### 7.2 Thread binding 层
|
||
|
||
`localThreadWorkspacePathInternal(sessionKey)` 与
|
||
`buildDesktopWorkspaceBindingInternal(sessionKey, ...)`
|
||
必须建立在“当前 sessionKey 已经真实存在”的前提下。
|
||
|
||
它们负责:
|
||
|
||
- 把线程身份映射成稳定目录
|
||
- 在同一线程下复用既有 binding
|
||
|
||
它们不负责:
|
||
|
||
- 替 UI 猜当前任务线是谁
|
||
- 把无 key 任务偷偷归并到 `main`
|
||
|
||
### 7.3 single-agent 入口
|
||
|
||
single-agent 入口必须在执行前验证:
|
||
|
||
1. 当前任务线是不是主线程以外的真实线程
|
||
2. `currentSessionKey` 是否已切换完成
|
||
3. `workingDirectory` 是否来自当前线程自己的 `TaskThread`
|
||
|
||
若不满足,应报线程绑定错误,而不是继续执行。
|
||
|
||
### 7.4 派生任务视图
|
||
|
||
`DerivedTasksController` 只能消费 session summary,不得反向定义线程身份。
|
||
|
||
也就是说:
|
||
|
||
- 它可以显示任务线
|
||
- 它可以反映状态
|
||
- 它不能成为“当前线程是谁”的真相源
|
||
|
||
线程身份仍然只能由 `TaskThread.threadId / currentSessionKey` 决定。
|
||
|
||
## 8. 迁移与兼容
|
||
|
||
### 8.1 现有 `main` 线程
|
||
|
||
现有主线程 `main` 继续保留,作为默认主会话。
|
||
|
||
### 8.2 历史上误共享 `main` 的任务线
|
||
|
||
对历史数据不做自动拆分迁移,原因是:
|
||
|
||
- 无法可靠推断哪些 UI 任务线原本应拆成独立线程
|
||
- 自动拆分会破坏既有消息上下文和 provider 会话连续性
|
||
|
||
处理原则:
|
||
|
||
1. 历史共享 `main` 的记录继续作为 `main`
|
||
2. 从修正版本开始,新建任务线必须创建独立 `sessionKey`
|
||
3. 对已暴露出共享问题的入口,优先阻止继续 silent fallback,并移除 prompt / runtime side-channel first-binding
|
||
|
||
### 8.3 未绑定任务线
|
||
|
||
任何未完成 `sessionKey` 分配或 `TaskThread` 创建的任务线,必须显式显示为:
|
||
|
||
- 未绑定
|
||
- 不可运行
|
||
- 需要创建线程
|
||
|
||
而不是隐式执行到 `main`。
|
||
|
||
## 9. 验收标准
|
||
|
||
设计修正完成后,必须满足以下验收标准:
|
||
|
||
1. 新建两个任务线时,它们生成两个不同 `sessionKey`。
|
||
2. 两个任务线的本地目录分别落在不同路径:
|
||
- `.xworkmate/threads/<task-a>`
|
||
- `.xworkmate/threads/<task-b>`
|
||
3. 切换任务线后,`currentSessionKey` 与右栏路径同步变化。
|
||
4. single-agent 请求里的 `sessionId / threadId / workingDirectory` 始终对应当前线程。
|
||
5. 任意非主线程缺少 `sessionKey` 时,执行被阻止,而不是回落到 `main`。
|
||
6. workspace 更新只接受结构化回写或显式绑定;prompt-only 文本不会进入线程 binding 主链。
|
||
|
||
## 10. 与现有架构文档的关系
|
||
|
||
本文补充的是“线程身份隔离约束”,重点回答:
|
||
|
||
- 为什么任务线必须先成为真实 `TaskThread`
|
||
- 为什么 `sessionKey` 才是 single-agent 工作目录的身份锚点
|
||
- 为什么 prompt side-channel 不能替代线程身份或 workspaceBinding
|
||
|
||
推荐阅读顺序:
|
||
|
||
1. `task-control-plane-unification.md`
|
||
3. `task-thread-session-key-isolation-20260329.md`(本文)
|