docs(spec): chat turn idempotency and session isolation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-11 13:15:07 +08:00
co-authored by Cursor
parent 8c8fa4721a
commit f4da2f179f
@@ -0,0 +1,218 @@
# 聊天回合幂等与项目切换隔离 — 设计规格
> 日期:2026-09-11
> 状态:已批准,进入实现
> 关联 RCA:重复消息写入、项目切换重复展示、全局流状态冲突
---
## 一、问题背景
三个缺陷叠加导致重复消息与错误行为:
1. **后端无幂等门禁**:同一 POST 可多次 INSERT 用户消息并独立执行 Recognizer/Coordinator。
2. **前端身份对账不完整**:本地 `msg-{timestamp}` 与历史 `msg-{dbId}` 无法识别为同一条。
3. **流运行态全局共享**:`streamAbortRef` / `isThinking` 跨 session,切项目会中断后台 turn。
数据库已证实:`session-6010-张工-private` 中相同正文在 15.86s 内写入两条用户消息(ID 24994/24995),无 `client_turn_id`。
---
## 二、目标
- 同一用户意图 → 一个 `chat_turn`、一条用户消息、一条助手回复。
- 项目切换只改展示上下文,不取消其他 session 的后台 turn。
- 弱网重试/断线重连复用同一 `client_turn_id`,不重复执行 Agent。
---
## 三、核心协议
### 3.1 客户端回合 ID
- 用户首次点击发送时生成 `client_turn_id = crypto.randomUUID()`。
- 用户消息与助手占位共享同一 turn。
- 自动/手动重试、断线重连必须复用,只有新输入才生成新 turn。
### 3.2 HTTP 请求
```http
POST /api/v1/chat/stream
Idempotency-Key: {client_turn_id}
Content-Type: application/json
```
```json
{
"session_id": "...",
"client_turn_id": "...",
"message": "...",
"user_id": 1,
"project_id": 6010
}
```
- 同 session 内同 key 不同正文 hash → `409 IDEMPOTENCY_KEY_REUSED`。
- 新客户端 **必须** 携带 `client_turn_id`。
### 3.3 chat_turns 权威表
```text
chat_turns (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
client_turn_id TEXT NOT NULL,
user_id BIGINT,
project_id BIGINT,
user_message_id BIGINT,
assistant_message_id BIGINT,
request_body_sha256 TEXT NOT NULL,
status TEXT NOT NULL, -- accepted|processing|completed|failed|cancelled
owner_token TEXT,
lease_expires_at TIMESTAMPTZ,
first_request_id TEXT,
last_request_id TEXT,
attempt_count INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
UNIQUE(session_id, client_turn_id)
)
```
**执行权门禁(必须包住 Agent):**
1. `INSERT ... ON CONFLICT DO NOTHING` 赢得执行权 → 写用户消息 → 串行执行 Agent → 写助手消息 → `completed`。
2. 已 `completed` → 返回既有 IDs,不重跑 Agent。
3. `processing` → 返回状态或附着已有流。
4. `failed` → 同 turn 恢复,不新建用户消息。
### 3.4 SSE 事件
**turn_ack(首确认):**
```json
{
"type": "turn_ack",
"turn": {
"client_turn_id": "...",
"user_message_id": 24994,
"status": "processing",
"duplicate": false
}
}
```
**done(完成):**
```json
{
"type": "done",
"done": {
"client_turn_id": "...",
"user_message_id": 24994,
"message_id": 25001,
"status": "completed"
}
}
```
### 3.5 断线续接
- `GET /api/v1/chat/turns/{client_turn_id}?session_id=...` 查询 turn 状态与 message IDs。
- 前端断线不新建 turn;先查状态再决定附着/展示完成。
---
## 四、前端身份合并
优先级:
1. `clientTurnId` 相同
2. `serverId` 相同
3. 迁移期弱兜底:同 session + role + content hash + 极近时间(非永久主键)
收到 `turn_ack` 后立即绑定用户消息 `serverId`;`done` 绑定助手 `serverId`。
---
## 五、Session 隔离
### 5.1 窗口拉取竞态
- `generationRef` + `AbortController`
- 响应校验 generation / sessionId / !aborted
- session 切换立即展示该 session 缓存骨架
### 5.2 流运行态
```text
Map<sessionId, Map<clientTurnId, TurnRuntime>>
```
- 切项目不 abort 其他 session
- `isThinking` 按当前 session 维度
- 停止仅针对指定 session + turn
### 5.3 持久化
- **废弃** 全量 `MESSAGES` localStorage
- 保留 `chat.window.{sessionId}` + `chat.draft.{sessionId}`
- 进行中 turn 只持久化 `clientTurnId` + 状态引用
---
## 六、同 session 串行
- 每 Session 内 turn 串行执行(mutex/queue)
- 不同 session 可并行
- 防止上下文乱序与数据竞争
---
## 七、止血(Phase 1)
幂等落地前:
- **关闭** chat stream 无幂等自动重发
- 瞬态错误只展示手动「重试」按钮
- 手动重试提示确认上一请求状态
---
## 八、验收标准
```text
发送项目 A 消息
→ A 一条用户消息 + 一个助手占位
→ 切到 B,A 后台继续
→ 回到 A:仍一条用户消息、一条助手回复
→ DB:一个 chat_turn、一条用户消息、一条助手消息
→ Coordinator/工作流副作用只执行一次
```
---
## 九、测试矩阵
| 场景 | 期望 |
| :--- | :--- |
| 本地 tmp vs 服务端 msg-dbId | 合并为一条 |
| 同 client_turn_id 并发 POST | 1 user + 1 assistant |
| 同正文不同 client_turn_id | 允许两轮 |
| 断线同 turn 重连 | 不新增用户消息、不重跑 Agent |
| A→B→A 窗口竞态 | B 晚到不覆盖 A |
| 切项目后台 turn | A 继续;回 A 无重复 |
---
## 十、实施顺序
1. 关闭无幂等自动重发
2. user_message_id + 前端身份合并
3. useChatWindow 竞态保护
4. chat_turns + 端到端幂等
5. session 串行 + turn_ack/续接
6. per-session 流运行态
7. 停用全量 messages 持久化
8. 测试矩阵 + 真实 Agent 验收报告