Files
Cyrene_v2/internal/services/gateway/ws_hub_turn.go
T
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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`
2026-10-04 14:37:37 +08:00

221 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// relay 是 ws.> 模式与各生产者桥接模式的 bus 处理器。ws.chat 主题是
// 流式触发器(跑 loop 并发 chunk);ws.* 主题成为其设计类型对应的
// 下行事件;被桥接的生产者主题(device_update、任务 output/done、
// automation result、reminder、subagent progress)被规范化为匹配的
// ws.* 事件类型。没有 WS 可见映射的事件会被丢弃,使内部 agent 间
// 流量永不泄露给客户端。
func (h *hub) relay(ctx context.Context, e bus.Event) error {
if e.Subject == wsChatSubject {
go h.streamChat(ctx, e.Payload)
return nil
}
if e.Subject == wsStreamSubject {
// OnStream→bus→relay 路径:loop 已经发布了结构化的
// model.StreamEvent;把它映射为带类型的下行事件。
h.broadcastStream(e.Payload)
return nil
}
ev, ok := normalizeEvent(e)
if !ok {
return nil
}
h.broadcast(ev)
return nil
}
// streamChat 是 ws.chat 触发器的入口。它解码请求,并在接了
// orchestrator 时把该轮路由经会话 orchestrator(design/15 的非阻塞
// busy/pending/resume 路径);否则回退到旧的直接运行方式,使未接
// orchestrator 的 hub 保持完全相同的 stream_chunk/stream_end 契约。
// 它跑在各自的 goroutine 上,因此慢的模型调用不会阻塞 bus 订阅
// worker。
//
// 它把编排器对本次提交的判定**交回调用方**(协同 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 SubmitResult{}
}
if req.SessionID == "" {
req.SessionID = "web"
}
// Web 主会话是 owner 的私有对话(JWT = owner),因此它绑定全权限
// env:记忆读写,行为不变。角色来自会话的绑定(design/22 §4.5):
// 切到角色 Y 的会话在下一轮以 Y 应答,而未绑定的/既有的会话继续
// 解析为全局默认角色。
msgs := userChatMessages(req.Text, req.Image)
// 通过 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 {
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(经其
// 注入的 TurnRunner)与旧回退路径共用的单元,因此无论由谁驱动,一轮
// 总是发出相同的 stream_chunk/stream_end 事件。当 loop 处于流式
// (接了 OnStream)时,结构化增量已经通过 OnStream→bus→relay 路径以
// message.*/thinking.*/tool_call.* 事件扇出,因此只有非流式路径才发出
// 一整段文本 chunk 以维持流契约。
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
turnID := newTurnID()
// 把轮次身份串进运行上下文,使 loop 把它盖在它发出的每个流式
// 事件上(message.*/thinking.*/tool_call.*);hub 随后把它放到
// 下行帧上。每一轮都取全新的 id —— 一个会话最多一个在途轮次
// —— 因此客户端能把一串增量关联到同一个助手轮次
// (design/16 §4.2)。
ctx = loop.WithTurnID(ctx, turnID)
if h.runner == nil || len(msgs) == 0 {
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}})
return &loop.Result{StopReason: "idle"}, nil
}
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs)
if err != nil {
h.log.Error("chat run", "err", err, "session", sessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return nil, err
}
if !h.streaming {
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"delta": res.Text}})
}
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
return res, nil
}
// wsBadRequestCode 是上行帧载荷非法时定向回发送者的机器可读错误码。它与
// ws_route.go 的 wsForbiddenCode 同形({type:"error", data:{code}}),
// 因此客户端只看 data.code 就能把「你没有这个权限」与「这个帧写错了」
// 区分开。
const wsBadRequestCode = "bad_request"
// interruptRequest 是解码后的 "interrupt" 上行载荷(design/15 §3)。
type interruptRequest struct {
SessionID string `json:"sessionId"`
// Scope 选择这次打断清到哪一层(协同项 A2,取值见 InterruptScope):
// 省略 / 空串 / "all" = 现状语义(取消在途回合并清空待处理队列),
// "current" = 只取消在途回合、保留待处理队列让它们照常续跑。
// 其余取值一律显式拒绝 —— 客户端以为队列保住了、实际被清空,
// 就是静默丢消息,正是 A2 要修掉的那类缺陷。
Scope string `json:"scope,omitempty"`
}
// parseInterruptScope 把上行 scope 解析为取值域内的口径。省略、空串与
// "all" 都落到现状语义(省略 = 什么都不改,因此老客户端只发
// {sessionId} 的行为逐字不变);"current" 是新增的保留队列口径;其余
// 取值返回错误,由调用方定向回 bad_request,绝不静默当默认执行。
//
// 取值大小写敏感(与契约里的枚举 id 同口径),只容忍首尾空白。
func parseInterruptScope(raw string) (InterruptScope, error) {
switch strings.TrimSpace(raw) {
case "", string(InterruptScopeAll):
return InterruptScopeAll, nil
case string(InterruptScopeCurrent):
return InterruptScopeCurrent, nil
default:
return "", fmt.Errorf("interrupt: 未知 scope %q(合法值:%q / %q)", raw, InterruptScopeAll, InterruptScopeCurrent)
}
}
// handleInterrupt 处理客户端的 "interrupt" 帧(design/15 §3 + 协同项 A2):
// 按 scope 让 orchestrator 取消该会话的在途回合,必要时保留待处理队列。
// 未接 orchestrator 时是空操作。
//
// 它是**连接级**的,因为非法 scope 必须定向回发送者一条
// {type:"error", data:{code:"bad_request"}}:只到发送者、不广播、也不打断
// 任何会话(显式报错,绝不静默按默认执行)。该帧只由 owner/admin 连接
// 分发到此(design/11 §7.2 B)。
//
// 缺 sessionId 时是空操作(既有行为,本次不改)。
func (c *wsConn) handleInterrupt(payload json.RawMessage) {
if c.h == nil {
return
}
var req interruptRequest
_ = json.Unmarshal(payload, &req)
scope, err := parseInterruptScope(req.Scope)
if err != nil {
c.h.log.Debug("ws interrupt rejected", "err", err, "scope", req.Scope)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}})
return
}
if c.h.orchestrator == nil || req.SessionID == "" {
return
}
c.h.orchestrator.InterruptScope(req.SessionID, scope)
}
// broadcastStream 把流式的 model.StreamEvent 映射为其下行 WS 事件类型,
// 并经 hub 的帧门扇出(design/11 §7.1):每一个结构化流帧都携带模型
// 输出,因此只到达 owner + admin。它跑在 bus 订阅 worker 上;seq 原子
// 分配,因此即便 loop 从多个 goroutine 发出事件,一串帧仍保持有序。
// SessionID/TurnID 随事件本身携带(loop 在轮次边界从运行上下文盖上
// 它们),因此帧带有客户端把它路由到活跃会话以及关联到某个助手
// 轮次所需的范围。
func (h *hub) broadcastStream(payload any) {
ev, ok := payload.(model.StreamEvent)
if !ok {
return
}
typ, data := streamEventData(ev)
h.broadcast(wsEvent{Type: typ, SessionID: ev.SessionID, TurnID: ev.TurnID, Seq: int(h.streamSeq.Add(1)), Data: data})
}
// chatStreamRequest 是解码后的 ws.chat 载荷。
type chatStreamRequest struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
// DeviceID 是客户端生成的发送浏览器/设备标识(design/16 §4.5),
// 例如 "web:chrome:a1b2c3";它可选,并随消息一同持久化,使 agent
// 知道主人是从哪一端发消息。
DeviceID string `json:"deviceId,omitempty"`
// 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 事件关联到
// 同一个助手轮次。它消耗 crypto/rand 源,源不可用时回退到纳秒
// 时间戳,因此总能产出 id(空的 turnId 会静默破坏客户端关联)。
func newTurnID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err == nil {
return "turn-" + hex.EncodeToString(b[:])
}
return "turn-" + time.Now().UTC().Format("20060102150405.000000000")
}