- 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`
221 lines
11 KiB
Go
221 lines
11 KiB
Go
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")
|
||
}
|