Files
Cyrene_v2/internal/services/gateway/ws_route.go
T
AskaEth 74b12d87ca
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(history,loop,gateway): 上下文续接的最新性/一致性三条(裁决 13.4.5 的面内部分)
本组只落地「在允许文件面内、且能用 design/15 既有规格推出」的部分;13.4.5 的「真并行 + 动态旧上下文」
与 design/15 §2.1/§2.2 的既有串行续接规格存在读法冲突,按规程**停下上报**(未自创语义)。

- history.Recent(续接窗口)并列打破:created_at DESC, rowid DESC —— Windows time.Now() 步长 ~0.44ms,
  用户连发必然同毫秒并列;修复前并列时取**更早**的 ⇒ 丢掉刚发出的最新 n 条且同毫秒内反插入序
- 上行 chat 不再经**可丢弃**的 bus:直达会话编排器的非阻塞 Submit(design/15 §2.1「会话消息不能被 drop」
  + §4「不阻塞是硬约束」);orchestrator 为 nil 时回退 bus,旧断言仍绿
- 被**取消**(中断)的回合把该会话 checkpoint 落定为不可恢复(用 context.WithoutCancel 写,快照保留可查询,
  仅在回合未落定时触发)⇒ 不再顶掉下一条消息的上下文
- 复核:build/vet 绿 + loop/gateway/history 三包测试与 -race 全绿;新增 5 条用例(含一条真 WS+真编排器+
  真 loop+真 persist 的集成用例);**3 条变异反证**精确复现「新消息被丢弃只见旧窗口」「窗口取早不取新」
  「bus 饱和时会话消息静默消失」,均已还原
- 待裁决(已上报主人):① 「同一会话多回合真并行」需改 checkpoint 键(sessionID:step 并发互相覆盖)、
  flow 图按轮键控、前端单槽 busy —— 均在本组文件面外;② 「动态吸收新到消息」在 design/15 **无规格**,
  属新语义,须先写设计;③ 非取消的错误退出同样留可恢复快照(同机制,未扩范围,因 resume_test 正钉住旧行为)
2026-10-04 13:56:35 +08:00

172 lines
7.2 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: 用户说「停下/算了」→ 取消在飞回合并清空该会话的
// 待处理队列,使下一条消息从干净状态开始。
c.h.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)
}