fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- 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:
1 parent
da3a386daf
commit
7512d593c2
18 files changed
+1573
-216
No files matched your search
@@ -45,14 +45,19 @@ func (h *hub) relay(ctx context.Context, e bus.Event) error {
|
||||
// orchestrator 的 hub 保持完全相同的 stream_chunk/stream_end 契约。
|
||||
// 它跑在各自的 goroutine 上,因此慢的模型调用不会阻塞 bus 订阅
|
||||
// worker。
|
||||
func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
//
|
||||
// 它把编排器对本次提交的判定**交回调用方**(协同 G2):REST 的 stream:true
|
||||
// 路径与 WS 帧共用这一段,前者据此回明确的 2xx/非 2xx/202/409,而不是在问
|
||||
// 编排器之前就先回 200 —— 那样客户端可能拿到 200 却什么都没发生。异步调用点
|
||||
// (`go h.streamChat(...)`,WS 帧与 bus 中继)照常丢弃返回值。
|
||||
func (h *hub) streamChat(ctx context.Context, payload any) SubmitResult {
|
||||
var req chatStreamRequest
|
||||
if b, err := json.Marshal(payload); err == nil {
|
||||
_ = json.Unmarshal(b, &req)
|
||||
}
|
||||
if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") {
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), SessionID: req.SessionID, Data: map[string]any{"stopReason": "idle"}})
|
||||
return
|
||||
return SubmitResult{}
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = "web"
|
||||
@@ -62,15 +67,17 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
// 切到角色 Y 的会话在下一轮以 Y 应答,而未绑定的/既有的会话继续
|
||||
// 解析为全局默认角色。
|
||||
msgs := userChatMessages(req.Text, req.Image)
|
||||
// 通过 ctx 把客户端的 deviceID 与行为模式串进 orchestrator,使
|
||||
// 持久化的用户轮次与助手回复都带上标记(design/16 §4.5、§5.1)。
|
||||
ctx = withMode(withDeviceID(ctx, req.DeviceID), req.Mode)
|
||||
// 通过 ctx 把客户端的 deviceID、行为模式与可选幂等键串进
|
||||
// orchestrator,使持久化的用户轮次与助手回复都带上标记,并让重复到达的
|
||||
// clientMsgId 被判重(design/16 §4.5、§5.1;协同 G4)。空 clientMsgId 是
|
||||
// 空操作,因此老客户端的行为逐字不变。
|
||||
ctx = withClientMsgHint(withMode(withDeviceID(ctx, req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID})
|
||||
env := h.envResolver.owner(ctx, req.SessionID)
|
||||
if h.orchestrator != nil {
|
||||
_ = h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
|
||||
return
|
||||
return h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
|
||||
}
|
||||
_, _ = h.runTurn(ctx, req.SessionID, msgs, env)
|
||||
return SubmitResult{Accepted: true, Immediate: true}
|
||||
}
|
||||
|
||||
// runTurn 执行一个 agent 轮次并广播其输出流。它是 orchestrator(经其
|
||||
@@ -194,6 +201,11 @@ type chatStreamRequest struct {
|
||||
// Mode 是客户端发送该轮时所用的行为模式(design/01 §3、
|
||||
// design/16 §5.1);它可选,按消息持久化,缺省时回退为 "chat"。
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// ClientMsgID 是客户端自带的幂等键(协同 G4):同一会话内同一个非空
|
||||
// clientMsgId 只承认第一条,重复到达不再落库、不再起回合,而是回一条
|
||||
// `chat_duplicate`(data 带既有 messageId)。它可选,省略时行为与加入该
|
||||
// 字段之前逐字相同。
|
||||
ClientMsgID string `json:"clientMsgId,omitempty"`
|
||||
}
|
||||
|
||||
// newTurnID 返回新的随机十六进制标识,用于把一串流式 WS 事件关联到
|
||||
@@ -206,14 +218,3 @@ func newTurnID() string {
|
||||
}
|
||||
return "turn-" + time.Now().UTC().Format("20060102150405.000000000")
|
||||
}
|
||||
|
||||
// publishChatStream 发布一个 ws.chat 流式触发器(REST 流式模式)。
|
||||
// deviceID 是发送方的浏览器/设备 id,mode 是客户端发送该轮时所使用的
|
||||
// 行为模式,二者都随载荷携带,使 ws.chat 消费方能给持久化的轮次打
|
||||
// 标记(design/16 §4.5、§5.1)。
|
||||
func (s *Server) publishChatStream(ctx context.Context, sessionID, text, image, deviceID, mode string) {
|
||||
if s.bus == nil {
|
||||
return
|
||||
}
|
||||
_ = s.bus.Publish(ctx, wsChatSubject, map[string]any{"sessionId": sessionID, "text": text, "image": image, "deviceId": deviceID, "mode": mode})
|
||||
}
|
||||
Reference in new issue
Block a user