Files
AskaEth c0dc435770 feat(gateway): 忙时并行帧层(turn_begin + steer_ack.turnId + interrupt.turnId)
新下行帧 turn_begin:编排器每启动一路恰好一帧(单路 kind:"turn" / 并行路 "parallel"),
data{kind, messageIds, leg, activeTurns};角色门 fail-closed 仅 owner+admin,未进 frameReachesAll。
steer_ack 增帧壳 turnId(与该路 turn_begin 同源)—— 并行下没有它配不上正确的 turn.end。
上行 interrupt.turnId(仅 scope:"current" 生效):精确停某一路;目标不存在 ⇒ 定向
{type:"error",data:{code:"bad_request"}},不广播、不动任何会话;省略 turnId = v0.65 原口径(全部在途路)。
帧序按实现:session_busy / pending_drain → message_persisted(user) → turn_begin
(规范草案 §2.9 的时序图需按实现更正:messageIds 必须是已落库的权威 id,故只能在落库之后)。
测试:编排器侧 + 真 WS/JWT 端到端;变异 5 条(每路一帧 / 帧壳 turnId / 目标化打断 / 取消筛选)。
Docs 待升 v0.66(由监工登记)。
2026-10-05 09:38:11 +08:00

298 lines
15 KiB
Go
Raw Permalink 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/logger"
"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)
}
emptyTurn := strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" && len(req.Documents) == 0
if h.runner == nil || emptyTurn {
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"
}
// 文档引用在本进程内解析(只认服务端存储名 + 确实存在),失败**可见**:
// 回一条机器可读的 error 帧并中止本回合,绝不静默丢掉附件(design/24 §4.4)。
docs, derr := h.resolveDocuments(req.Documents)
if derr != nil {
logger.WithSource(h.log, documentsLogSource).Warn("chat documents rejected",
"session", req.SessionID, "count", len(req.Documents), "err", derr)
h.broadcast(wsEvent{Type: "error", SessionID: req.SessionID, Data: map[string]any{"code": chatDocumentErrorCode(derr)}})
return SubmitResult{}
}
// Web 主会话是 owner 的私有对话(JWT = owner),因此它绑定全权限
// env:记忆读写,行为不变。角色来自会话的绑定(design/22 §4.5):
// 切到角色 Y 的会话在下一轮以 Y 应答,而未绑定的/既有的会话继续
// 解析为全局默认角色。
msgs := chatTurnMessages(req.Text, req.Image, docs)
// 通过 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})
// 可选的并行声明走同一条 ctx 通道(design/15 §2.5):false 是空操作,
// 省略 `parallel` 的老客户端与加入该字段之前逐字相同。
ctx = withParallelTurn(ctx, req.Parallel)
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 优先取 ctx 上已有的那个(design/15 §2.5.9 的 ⚠️ 必改项):
// 编排器在**启动该路之前**就生成了它,并把同一份 id 交给 runTurn —— 因为它
// 发出 `turn_begin` 帧时必须报出这个 id,而客户端按 turnId
// 两级分桶。若这里再自己生成一个,两处 id 不同,分桶会立即失配。
//
// 未挂载(子代理 / 后台思考 / 未接编排器的旧回退路径)时才新铸一个,因此
// 单路路径的帧上仍是一个**每回合全新的随机 id** ⇒ 对客户端无可见差异。
turnID := loop.TurnIDFrom(ctx)
if turnID == "" {
turnID = newTurnID()
}
// 把轮次身份串进运行上下文,使 loop 把它盖在它发出的每个流式
// 事件上(message.*/thinking.*/tool_call.*);hub 随后把它放到
// 下行帧上,客户端因此能把一串增量关联到同一个助手轮次
// (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"`
// TurnID 是「只停这一路」的目标(忙时并行规范草案 §2.3):**只在
// scope:"current" 时生效**;省略 scope 或 scope:"all" 时按契约**忽略**它
// ——那两个取值的语义本就是「全部在途路 + 清 pending」。
//
// 指向不存在的路(已被收尾 / 已被取消 / id 写错)⇒ 定向回
// `{type:"error",data:{code:"bad_request"}}`,**绝不**静默降级成「停全部」:
// 那会把用户「只停这一路」的意图执行成「停掉所有路」。
TurnID string `json:"turnId,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 时是空操作(既有行为,本次不改)。
//
// 目标化打断(忙时并行规范草案 §2.3):`scope:"current"` + `turnId` ⇒ 只停那
// 一路(InterruptTurn)。其它 scope 下 turnId 按契约**忽略**(它们的语义本就是
// 「全部在途路 + 清队列」),这里只留一条 Debug 让「发了但没生效」可被追溯。
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
}
if req.TurnID != "" && scope != InterruptScopeCurrent {
c.h.log.Debug("ws interrupt turnId ignored", "session", req.SessionID, "scope", scope)
}
if req.TurnID != "" && scope == InterruptScopeCurrent {
c.interruptOneTurn(req.SessionID, req.TurnID)
return
}
c.h.orchestrator.InterruptScope(req.SessionID, scope)
}
// interruptOneTurn 执行一次「只停这一路」并如实回报结果:目标不存在时定向回
// 发送者一条 {type:"error", data:{code:"bad_request"}}(只到发送者、不广播、也
// 不动任何会话),**绝不**静默降级成「停掉全部在途路」。
func (c *wsConn) interruptOneTurn(sessionID, turnID string) {
if c.h.orchestrator.InterruptTurn(sessionID, turnID) {
return
}
c.h.log.Debug("ws interrupt turn not found", "session", sessionID, "turn", turnID)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}})
}
// 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"`
// Documents 是本回合附带的文档引用(design/24 §6.1),与 REST 的
// `chatRequest.documents` **同形**:只给服务端存储名,绝不接受 URL。
// 上行帧仍有 4096 字节硬上限 ⇒ 文档实际上走 REST;这里保持同形解析,
// 使两条入口不会分叉(解析见 resolveDocuments)。
Documents []chatDocumentRef `json:"documents,omitempty"`
// Parallel 是「把本条作为**独立一路**处理」的显式声明(design/15 §2.5,
// 主人 2026-10-05 裁决 B):它可选,只有会话正忙且并发额度未满时才被照准
// (额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`)。
// 省略它时行为与加入该字段之前逐字相同 —— 服务端**不做**启发式自动升级,
// 触发权在客户端。
Parallel bool `json:"parallel,omitempty"`
}
// resolveDocuments 解析本回合携带的文档引用(design/24 §4.4/§6.1)。一条都不带
// 是空操作;未接解析器(未接线的构建 / 单元测试构造的裸 hub)时**明确报错**,
// 绝不把附件静默丢掉。
func (h *hub) resolveDocuments(refs []chatDocumentRef) ([]model.Document, error) {
if len(refs) == 0 {
return nil, nil
}
if h.documents == nil {
return nil, documentsUnavailableError()
}
return h.documents(refs)
}
// 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")
}