Files
Cyrene_v2/internal/services/gateway/ws_hub_turn.go
T
AskaEth 5494c31474
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): interrupt 支持 scope(当前/全部),默认维持现状(协同 A2)
- 原状:interrupt{sessionId} 取消在飞回合**并清空 pending** ⇒ 客户端「打断模式」连发会被静默丢弃
- 修法:新增 scope 三态(与项目既有「省略=保留现状」口径一致)
  · 省略 或 "all" = 现状语义(取消当前 + 清 pending)
  · "current" = **只取消当前回合、保留 pending**(pending 继续按编排器规则续跑)
  · 非法值 → **定向回错误**(只回发送者,不广播),显式报错而非静默当默认
- 下行 session_interrupted 的 dropped 如实反映**真的被丢弃的条数**(scope=current 时通常为 0)
- 复核:gateway 包 build/vet/测试绿(新增 interrupt_scope_* 两组用例);后端 :10942 全程未停
2026-10-04 14:21:52 +08:00

220 lines
10 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。
func (h *hub) streamChat(ctx context.Context, payload any) {
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
}
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,使
// 持久化的用户轮次与助手回复都带上标记(design/16 §4.5、§5.1)。
ctx = withMode(withDeviceID(ctx, req.DeviceID), req.Mode)
env := h.envResolver.owner(ctx, req.SessionID)
if h.orchestrator != nil {
_ = h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
return
}
_, _ = h.runTurn(ctx, req.SessionID, msgs, env)
}
// 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"`
}
// 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")
}
// 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})
}