fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
This commit is contained in:
AskaEth committed 2026-10-04 14:37:37 +08:00
1 parent da3a386daf
commit 7512d593c2
18 files changed
+1573 -216

No files matched your search

+23 -9
View File
@@ -110,13 +110,18 @@ type loginResponse struct {
// Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16
// §5.1);它可选,并按消息持久化以供气泡渲染,省略时
// 回退到 "chat"。
// ClientMsgID 是客户端自带的幂等键(Android 协同 §G4):它可选,同一会话内
// 同一个非空值只承认第一条 —— 重复到达返回既有消息 id(`{duplicate:true,
// messageId}`)而不再落库、不再起回合,客户端据此把本地乐观气泡对齐到服务端
// 真相(替代「靠文本相等」的脆弱匹配)。省略它时行为与加入该字段之前逐字相同。
type chatRequest struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
Mode string `json:"mode,omitempty"`
Stream bool `json:"stream"`
SessionID string `json:"sessionId"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
Mode string `json:"mode,omitempty"`
ClientMsgID string `json:"clientMsgId,omitempty"`
Stream bool `json:"stream"`
}
// chatResponse 是同步聊天回复的载荷。
@@ -236,8 +241,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
if req.Stream {
s.publishChatStream(r.Context(), req.SessionID, req.Text, req.Image, req.DeviceID, req.Mode)
writeJSON(w, http.StatusOK, map[string]any{"stream": true})
// 协同 G2:不再「先回 200,再看有没有落地」。与 WS 的 chat 帧同一条
// 直达编排器的路径,由 handleChatStream 按判定回明确的状态码。
s.handleChatStream(w, r, req)
return
}
if s.runner == nil {
@@ -252,8 +258,16 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
// 来自会话的绑定(design/22 §4.5)。
msgs := userChatMessages(req.Text, req.Image)
env := s.envResolver.owner(r.Context(), req.SessionID)
// 客户端的 deviceID、行为模式与可选幂等键(协同 G4)都经 ctx 串进编排器。
ctx := withClientMsgHint(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID})
if s.hub.orchestrator != nil {
sr, res := s.hub.orchestrator.SubmitSync(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), req.SessionID, msgs, env)
sr, res := s.hub.orchestrator.SubmitSync(ctx, req.SessionID, msgs, env)
// 重复到达(同一个 clientMsgId):明确告知既有消息 id,而不是再跑一遍
// 回合、再落一条用户消息(协同 G4)。
if sr.Duplicate {
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": req.SessionID})
return
}
if !sr.Accepted {
if sr.PendingFull {
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))