Files
Cyrene_v2/internal/services/gateway/ws_route.go
T
AskaEth 19f296a2ab
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway): 回合围栏堵住「中断后立刻发新消息被旧快照吞掉」的时序竞态(13.4.5 最新性)
- 根因(已确定性复现,非测试问题):中断把会话立即置 idle ⇒ 紧随的新消息被当成空闲回合**立即开跑**,
  而被取消回合的快照收尾在**它自己的 goroutine** 里、直到 Run 返回前才落定 ⇒ 新回合可能读到上一回合
  中途留下的**可恢复**快照(stop_reason=tool_calls)并把调用方刚传进来的消息**整段替换**
  (现场证据:DB 里 web:1(step=1,tool_calls) 与 web:2(step=2,stop) 并存,LoadLatest 选中 web:1;
  把收尾窗口拉长到 300ms ⇒ 旧码 3/3 必红)
- 修法:新增 session_turn_fence.go —— 把「同一会话最多一个在途回合」从约定变成**可等待的围栏**:
  新消息照旧被立即接受、Submit 立即返回(不阻塞硬约束不变),但其**回合启动**等上一回合真正返回
  (那时被放弃的快照已落定);等待同时盯本回合 ctx,不收敛的上一回合不会永久挂住会话
- 复核:整包 `go test ./internal/services/gateway/ -count=1` 绿(隔离副本 0/12);`-shuffle=on` ×3 绿;
  **全树 `go test ./... -count=1` = 58 包 ok / 0 FAIL**;变异两处(HEAD 原码跑新用例 3/3 红、拿掉等待即红)已还原;
  交叉验证:单独打回 loop 的「只有 tool_calls 可恢复」改动后,新用例仍 3/3 红 ⇒ 两侧修法互补
- 顺带:A2 的 `ws_route.go` 一行(定向回 bad_request 需要发送者的 conn;hub 级无按连接寻址入口)
- 遗留(登记):loop 侧 `checkpoint.go` 的恢复会整段替换调用方消息 ⇒ 建议加一致性判据(checkpoint 窗口
  最后一条用户消息 ≠ 调用方最新消息则不恢复);围栏未覆盖 orchestrator==nil 的 legacy 直连路径
2026-10-04 14:25:13 +08:00

174 lines
7.4 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"
"encoding/json"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// wsForbiddenCode 是非 owner/admin 连接发送任何上行指令时收到的帧的机器可读
// 错误码(design/11 §7.2 B)。它沿用本文件既有的错误帧形状 ——
// wsEvent{Type: "error", Data: map[string]any} —— 客户端用它已经认识的 code
// 解码,仅凭这个键就能把「你没有这个权限」与处理函数级失败区分开。
const wsForbiddenCode = "forbidden"
// route 按类型分发一个已解码的上行(客户端→服务端)帧。
//
// 角色门(design/11 §7.2 B,主人于 2026-10-03 决策):非 owner/admin 连接
// 不得发送任何应用内指令。该检查是本函数的第一条语句,且默认拒 —— 它刻意
// 不是一张「禁止类型」清单 —— 因此后续里程碑新增的帧类型在有人显式登记
// 之前一律被拒。viewer 的 /ws/chat 连接因此是只读监视器:它跟随会话状态机,
// 但永远无法驱动模型、取消回合、读取子代理上下文或任务输出,也不能输入语音。
// 每个被拒的帧只在那一根连接上得到 {type:"error", data:{code:"forbidden"}}
// 的应答;不广播、不进入任何处理函数,因此不产生模型调用,也不落库。
//
// 放宽此门:viewer 侧的上行帧只有先在设计里给它开口、并登记到 design/11 §7.2
// 之后才允许(「必须先在设计里给它开口并登记白名单」)。下方的一律拒绝正是
// 让「忘记这一步」仍然安全的保障,所以放开某一个类型意味着把它换成一张
// 登记表,而不是删掉它。
//
// 协议级帧(ping/pong/close)不会到达这里 —— gorilla 在 ReadMessage 内部
// 处理 —— 因此这道门不会杀死连接。完全不是 JSON 的帧由 readPump 在分发前
// 应答;它不带类型、触发不了任何东西,所以保留解析层的应答。
func (c *wsConn) route(in wsInbound) {
if !ownerAdminClaims(c.claims) {
c.h.log.Debug("ws inbound rejected", "type", in.Type, "role", claimRole(c.claims))
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsForbiddenCode}})
return
}
switch in.Type {
case "mode_switch":
c.publishPublic("ws.mode_switch", rawData(in.Data))
case "chat":
// design/15 §2.1:会话消息「不能被 drop」——bus 的有界订阅缓冲满时
// 会丢弃事件(那是给事件/通知用的通道),而用户消息被丢就是上下文
// 不完整。因此上行 chat 直接进入会话编排器的非阻塞 Submit(裁决
// 13.4.5:忙时入 pending、续接更新的旧上下文),不经 bus。
c.submitChat(rawData(in.Data))
case "interrupt":
// design/15 §3: 用户说「停下/算了」→ 取消在飞回合;待处理队列按
// data.scope 决定去留(协同项 A2:省略/"all" = 连队列一起清,
// "current" = 只取消当前回合并保留队列)。非法 scope 由连接级
// 处理函数定向回 bad_request,因此这里调连接自己的方法。
c.handleInterrupt(in.Data)
case "voice_input":
// 语音经注入的 STT→chat→TTS 管线处理。没有 voice service 时回一个
// 明确的错误,而不是把帧丢掉。音频回复作为 voice_output 事件发在
// 发起请求的那根连接上。
c.handleVoiceInput(in)
case "subagent_ask":
c.handleSubagentAsk(in)
case "task_output_query":
c.handleTaskOutputQuery(in)
default:
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "unknown event type"}})
}
}
// subagentAskRequest 是 subagent_ask 帧的解码载荷。Mode 选择审计窗口
// (""/full/tail/keyword);n 界定 tail 查询的条数;query 是 keyword 查询的
// 关键词。
type subagentAskRequest struct {
ID string `json:"id"`
Mode string `json:"mode"`
N int `json:"n"`
Query string `json:"query"`
}
// taskOutputRequest 是 task_output_query 帧的解码载荷。
type taskOutputRequest struct {
TaskID string `json:"taskId"`
}
// handleSubagentAsk 审计某个子代理的上下文,并以携带真实上下文窗口的
// ws.subagent_event 帧回复。未注入 auditor(gateway 没接 scheduler)时回一个
// 明确的错误,而不是编造一个 "pending" 快照。
func (c *wsConn) handleSubagentAsk(in wsInbound) {
if c.h.auditor == nil {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "subagent audit unavailable"}})
return
}
var req subagentAskRequest
_ = json.Unmarshal(in.Data, &req)
if req.ID == "" {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "subagent id required"}})
return
}
mode := orchestration.AuditMode(req.Mode)
if mode == "" {
mode = orchestration.AuditFull
}
res, err := c.h.auditor.AuditContext(context.Background(), req.ID, mode, req.N, req.Query)
if err != nil {
c.h.log.Debug("subagent audit", "id", req.ID, "err", err)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": err.Error()}})
return
}
c.enqueueJSON(wsEvent{Type: "subagent_event", Data: res})
}
// handleTaskOutputQuery 取回 taskId 的真实任务输出,并以 ws.task_output 帧回复。
// 未注入 task store 时回一个明确的错误,而不是空的 {}。仍在运行的任务返回迄今
// 已捕获的输出。
func (c *wsConn) handleTaskOutputQuery(in wsInbound) {
if c.h.taskOutputs == nil {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "task output unavailable"}})
return
}
var req taskOutputRequest
_ = json.Unmarshal(in.Data, &req)
if req.TaskID == "" {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "task id required"}})
return
}
ev, err := c.h.taskOutputs.TaskOutput(context.Background(), req.TaskID)
if err != nil {
c.h.log.Debug("task output query", "id", req.TaskID, "err", err)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": err.Error()}})
return
}
text := ev.Output.Stdout
if ev.Output.Stderr != "" {
if text != "" {
text += "\n"
}
text += ev.Output.Stderr
}
c.enqueueJSON(wsEvent{Type: "task_output", Data: map[string]any{
"taskId": ev.ID,
"output": text,
"status": string(ev.Status),
}})
}
// claimRole 为上行业务拒绝的日志记录标注连接角色。claims 为 nil 的连接
// (单元测试构造的 fail-closed 裸连接)记为 "none",而不是解引用空指针。
func claimRole(claims *auth.Claims) string {
if claims == nil {
return "none"
}
return string(claims.Role)
}
// submitChat 把一条 chat 上行帧直接交给会话编排器(design/15 §2.1/§2.2),
// 而不是经 bus 转发:会话消息不得依赖可丢弃的通道(见 route 的 chat 分支)。
// 解码、角色解析与用户消息落库都在该 goroutine 上完成,因此读泵绝不等待
// 回合(design/15 §4「不阻塞是硬约束」)。
//
// 未接编排器时(单元测试构造的裸 hub、或未接编排器的构建)回退到既有的
// bus 触发器路径,使 hub 的 ws.chat 契约逐字节不变。
func (c *wsConn) submitChat(payload any) {
if c.h == nil {
return
}
if c.h.orchestrator == nil {
c.publishPublic(wsChatSubject, payload)
return
}
// 回合自身的取消由编排器持有(Interrupt/InterruptAll 取消它),
// 因此这里的基 ctx 只需是存活到回合结束的背景上下文。
go c.h.streamChat(context.Background(), payload)
}