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(由监工登记)。
This commit is contained in:
AskaEth committed 2026-10-05 09:38:11 +08:00
1 parent e5cc9c6054
commit c0dc435770
13 files changed
+800 -45

No files matched your search

+115 -16
View File
@@ -4,6 +4,7 @@ import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -12,14 +13,17 @@ import (
// 2026-10-05 裁决 B)。它从 session_orchestrator.go 拆出,使编排器本体继续只留
// 状态机,并保持在「单文件 ≤500 行 / 函数 ≤80 行」的硬约束内。
//
// 它做四件事:
// 它做五件事:
// - 解析上行的并行声明(ctx 通道,与 deviceID / mode / 幂等键同一种机制);
// - 判定并发额度(默认 2,硬上限 3,<2 = 关闭真并行 ⇒ 完全回落到 steer-only 现状);
// - 在状态锁下登记一条新路(自己的世代号 / turnId / 取消链 / checkpoint 归属键);
// - 忙态帧的在途路数(activeTurns)与排队原因(reason)。
// - 忙态帧的在途路数(activeTurns)与排队原因(reason);
// - 下发新帧 `turn_begin`(`publishLegStart` / `emitTurnBegin`):单路
// kind:"turn"、并行路 kind:"parallel",两条路径都发。`steer_ack` 的帧壳
// turnId 由 session_steer.go 的转向游标带出,与这里**取值同源**(leg.turnID)。
//
// 它**不做**(留给后续切片):`turn_begin` 帧的发射、`steer_ack` 的帧壳 turnId、
// 写类工具的文件级互斥、第二路的进行态 system 摘要。预留点见 turnLeg 的字段。
// 它**不做**(留给后续切片):写类工具的文件级互斥、第二路的进行态 system 摘要。
// 预留点见 turnLeg 的字段。
// 并发额度的取值口径(design/15 §2.5.7)。
const (
@@ -35,6 +39,11 @@ const (
pendingAckReasonParallelLimit = "parallel_limit"
// parallelLogSource 是并发路相关日志的子功能来源标签。
parallelLogSource = "session-parallel"
// turn_begin 的 kind 取值(忙时并行规范草案 §2.4):服务端**实际**如何起这
// 一路 —— 新回合 / 续跑 = "turn",显式声明并行且照准 = "parallel"。它**仅供
// 展示**,客户端的状态机一律按「messageIds 是否含本气泡的 messageId」判定。
turnBeginKindTurn = "turn"
turnBeginKindParallel = "parallel"
)
// turnLeg 是会话内一条在途回合(design/15 §2.5 的「路」)。
@@ -47,16 +56,16 @@ const (
// goroutine 读取 turnID/gen(出生后不可变)。
type turnLeg struct {
// turnID 是本路的权威回合标识(design/15 §2.5.9):编排器生成,挂进 runCtx
// 的 loop.WithTurnID,并由 loop 盖在该路全部流帧上。后续切片的 `turn_begin`
// 帧与 `steer_ack` 的帧壳 turnId 直接取它 —— 两处必须同值,否则客户端按
// 的 loop.WithTurnID,并由 loop 盖在该路全部流帧上。`turn_begin` 帧与
// `steer_ack` 的帧壳 turnId 都直接取它 —— 两处必须同值,否则客户端按
// turnId 两级分桶会立刻失配。
turnID string
// gen 是本路的世代号:会话内单调、永不复用。它取代了旧的「会话当前代次」
// 概念 —— 陈旧判据从「st.gen == gen」变成「本路还在 st.legs 里」,因此启动
// 第二路绝不会让第一路变成陈旧。
gen uint64
// leg 是本路的会话内序号(1 基)。它预留给后续切片的 `turn_begin.data.leg`
//(「并行路 2」的展示口径),本切片只在日志里用它。
// leg 是本路的会话内序号(1 基),即 `turn_begin.data.leg`(「并行路 2」的
// 展示口径)。除了发帧,日志也用它。
leg int
// parallel 报告本路是否为**显式声明的并行路**:它决定 checkpoint 归属键
// (单路逐字等于 sessionID,并行路为 {sessionID}#{turnID})。
@@ -157,12 +166,13 @@ func (o *Orchestrator) startParallelLocked(ctx context.Context, st *sessionState
activeTurns := len(st.legs)
o.mu.Unlock()
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, leg.gen))
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, leg.gen, leg.turnID))
o.emitState(sessionID, EventSessionBusy, busyPayload(sessionID, ModeThinking, activeTurns))
logger.Scoped(o.log, source, parallelLogSource).Info("parallel leg started",
"session", sessionID, "turn", leg.turnID, "leg", leg.leg, "active_turns", activeTurns)
o.rememberTurnDocuments(sessionID, leg.gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, mode, env.PersonaID))
o.rememberVoiceTurn(sessionID, leg.gen, voiceReplySinkFrom(ctx))
// 落库 → 按路配对 → `turn_begin{kind:"parallel"}`:messageIds 必须是刚落库的
// 权威 id,因此这三步的顺序不能颠倒(与单路启动共用同一段)。
o.publishLegStart(ctx, sessionID, leg, messages, deviceID, mode, env.PersonaID, activeTurns)
go o.goTurn(sessionID, leg, turn, env, runCtx, cancel, deviceID, mode)
return SubmitResult{Accepted: true, Immediate: true, Parallel: true, MessageID: hint.MessageID, TurnID: leg.turnID}
}
@@ -206,21 +216,45 @@ func (o *Orchestrator) legLocked(st *sessionState, gen uint64) *turnLeg {
return st.legs[gen]
}
// cancelLegsLocked 取消该会话当前**全部**在途路(design/15 §2.5.4:省略 turnId 的
// scope 作用于每一路),返回真的取消了几路。每路各有自己的取消链,因此取消一路
// 绝不影响另一路已发起的工具(I3′)。
// legByTurnIDLocked 按权威 turnId 取该会话的在途路;不存在时返回 nil。turnId
// 是客户端手上的唯一目标标识(`interrupt.turnId`),因此「这条路还在吗」只能在
// 状态锁下按它回答 —— 已收尾 / 已取消的路会被从 st.legs 摘掉,于是「不存在」
// 与「已经停了」是同一个答案。
//
// 必须在 o.mu 下调用。
func (o *Orchestrator) cancelLegsLocked(st *sessionState) int {
func (o *Orchestrator) legByTurnIDLocked(st *sessionState, turnID string) *turnLeg {
if st == nil || turnID == "" {
return nil
}
for _, leg := range st.legs {
if leg.turnID == turnID {
return leg
}
}
return nil
}
// cancelLegsLocked 取消该会话的在途路,返回真的取消了几路。turnID 为空 = 作用于
// **每一路**(design/15 §2.5.4:省略 turnId 的 scope);非空 = 只作用于该 turnId
// 的那一路(忙时并行规范草案 §2.3 的目标化打断)。每路各有自己的取消链,因此取消
// 一路绝不影响另一路已发起的工具(I3′)。
//
// 必须在 o.mu 下调用。
func (o *Orchestrator) cancelLegsLocked(st *sessionState, turnID string) int {
if st == nil {
return 0
}
canceled := 0
for _, leg := range st.legs {
if turnID != "" && leg.turnID != turnID {
continue
}
if leg.cancel != nil {
leg.cancel()
}
canceled++
}
return len(st.legs)
return canceled
}
// busyPayload 组装忙态 / 空闲帧的载荷(design/15 §2.6):既有键(sessionId / mode)
@@ -240,3 +274,68 @@ func pendingAckPayload(sessionID string, mode Mode, depth int, parallelLimit boo
}
return data
}
// legKind 返回这一路在 `turn_begin.data.kind` 里的取值(规范草案 §2.4):服务端
// **实际**如何起它 —— 显式声明并行且照准 ⇒ "parallel",新回合 / 续跑 ⇒ "turn"。
// 它只供展示,客户端的状态机不必依赖它。
func legKind(leg *turnLeg) string {
if leg != nil && leg.parallel {
return turnBeginKindParallel
}
return turnBeginKindTurn
}
// userMessageIDs 取出这一路已落库用户消息的**权威** id(客户端靠它把气泡钉到
// 这一路)。没有落库行(未接历史存储、或该次写入失败 —— 那条失败路径已由
// persistUserMessages 带来源记 Error)时返回**空数组**而不是 nil:契约里
// `messageIds` 是数组,null 会让严格解码的客户端直接报错。
func userMessageIDs(msgs []history.Message) []string {
ids := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.ID != "" {
ids = append(ids, m.ID)
}
}
return ids
}
// publishLegStart 在**起了一路之后**统一做三件事(design/15 §2.5.9):把刚落库的
// 用户消息按路配对(文档结论回写,design/24 §6.1)、挂本路的语音收尾回调(协同
// C1)、并下发 `turn_begin`。顺序是刻意的:`turn_begin.data.messageIds` 必须是
// **服务端权威的**消息 id(客户端靠它把这些气泡置为「已受理·回答中」并按 turnId
// 分桶),因此它只能在落库之后发。
//
// 这里落库的只有**此刻到达**的那些消息(已播种的转录早就持久了,重追加会重复整个
// 对话),且只写**引用** —— 抽取结论要等回合跑起来才有,由回合结算补进同一列
// (两段式见 session_document_status.go)。
//
// 单路与并行路共用这一段,于是两条启动路径的帧序完全同形 —— 客户端只需要实现一
// 遍「turn_begin → 灰态」。
func (o *Orchestrator) publishLegStart(ctx context.Context, sessionID string, leg *turnLeg, messages []model.Message, deviceID, mode, personaID string, activeTurns int) {
persisted := o.persistUserMessages(ctx, sessionID, messages, deviceID, mode, personaID)
o.rememberTurnDocuments(sessionID, leg.gen, persisted)
o.rememberVoiceTurn(sessionID, leg.gen, voiceReplySinkFrom(ctx))
o.emitTurnBegin(sessionID, leg, userMessageIDs(persisted), activeTurns)
}
// emitTurnBegin 下发一次 `turn_begin`(design/15 §2.5.9 / 规范草案 §2.4):帧壳
// `turnId` 是该路的权威回合标识(与它全部流帧的 turnId 同值 ⇒ 客户端可两级分桶),
// data 带 {kind, messageIds, leg, activeTurns}。
//
// `messageIds` 为空数组时**仍然发**:客户端的绑定是空操作,而「起了一路就恰好发
// 一帧」是可数的契约(leg / activeTurns 仍有用),按有无消息 id 决定发不发反而会让
// 帧数随存储接线而漂移。
func (o *Orchestrator) emitTurnBegin(sessionID string, leg *turnLeg, messageIDs []string, activeTurns int) {
if leg == nil {
return
}
if messageIDs == nil {
messageIDs = []string{}
}
o.emitTurnState(sessionID, leg.turnID, EventTurnBegin, map[string]any{
"kind": legKind(leg),
"messageIds": messageIDs,
"leg": leg.leg,
"activeTurns": activeTurns,
})
}