docs(spec): chat turn idempotency and session isolation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+218
@@ -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 验收报告
|
||||
Reference in New Issue
Block a user